UNPKG

chrome-devtools-frontend

Version:
935 lines (832 loc) • 33.5 kB
// Copyright (c) 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* eslint-disable rulesdir/no_underscored_properties */ import * as Common from '../common/common.js'; import * as Host from '../host/host.js'; import * as i18n from '../i18n/i18n.js'; import * as Platform from '../platform/platform.js'; import * as SDK from '../sdk/sdk.js'; import * as UI from '../ui/ui.js'; import {AnimationGroupPreviewUI} from './AnimationGroupPreviewUI.js'; import {AnimationEffect, AnimationGroup, AnimationImpl, AnimationModel, Events} from './AnimationModel.js'; // eslint-disable-line no-unused-vars import {AnimationScreenshotPopover} from './AnimationScreenshotPopover.js'; import {AnimationUI} from './AnimationUI.js'; export const UIStrings = { /** *@description Timeline hint text content in Animation Timeline of the Animation Inspector */ selectAnEffectAboveToInspectAnd: 'Select an effect above to inspect and modify.', /** *@description Text to clear everything */ clearAll: 'Clear all', /** *@description Tooltip text that appears when hovering over largeicon pause button in Animation Timeline of the Animation Inspector */ pauseAll: 'Pause all', /** *@description Title of the playback rate button listbox */ playbackRates: 'Playback rates', /** *@description Text in Animation Timeline of the Animation Inspector *@example {50} PH1 */ playbackRatePlaceholder: '{PH1}%', /** *@description Text of an item that pause the running task */ pause: 'Pause', /** *@description Button title in Animation Timeline of the Animation Inspector *@example {50%} PH1 */ setSpeedToS: 'Set speed to {PH1}', /** *@description Title of Animation Previews listbox */ animationPreviews: 'Animation previews', /** *@description Empty buffer hint text content in Animation Timeline of the Animation Inspector */ listeningForAnimations: 'Listening for animations...', /** *@description Tooltip text that appears when hovering over largeicon replay animation button in Animation Timeline of the Animation Inspector */ replayTimeline: 'Replay timeline', /** *@description Text in Animation Timeline of the Animation Inspector */ resumeAll: 'Resume all', /** *@description Title of control button in animation timeline of the animation inspector */ playTimeline: 'Play timeline', /** *@description Title of control button in animation timeline of the animation inspector */ pauseTimeline: 'Pause timeline', /** *@description Title of a specific Animation Preview *@example {1} PH1 */ animationPreviewS: 'Animation Preview {PH1}', }; const str_ = i18n.i18n.registerUIStrings('animation/AnimationTimeline.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); const nodeUIsByNode = new WeakMap<SDK.DOMModel.DOMNode, NodeUI>(); const playbackRates = new WeakMap<HTMLElement, number>(); let animationTimelineInstance: AnimationTimeline; export class AnimationTimeline extends UI.Widget.VBox implements SDK.SDKModel.SDKModelObserver<AnimationModel> { _gridWrapper: HTMLElement; _grid: Element; _playbackRate: number; _allPaused: boolean; _animationsContainer: HTMLElement; _playbackRateButtons!: HTMLElement[]; _previewContainer!: HTMLElement; _timelineScrubber!: HTMLElement; _currentTime!: HTMLElement; _popoverHelper!: UI.PopoverHelper.PopoverHelper; _clearButton!: UI.Toolbar.ToolbarButton; _selectedGroup!: AnimationGroup|null; _renderQueue!: AnimationUI[]; _defaultDuration: number; _duration: number; _timelineControlsWidth: number; _nodesMap: Map<number, NodeUI>; _uiAnimations: AnimationUI[]; _groupBuffer: AnimationGroup[]; _previewMap: Map<AnimationGroup, AnimationGroupPreviewUI>; _symbol: symbol; _animationsMap: Map<string, AnimationImpl>; _timelineScrubberLine?: HTMLElement; _pauseButton?: UI.Toolbar.ToolbarToggle; _controlButton?: UI.Toolbar.ToolbarToggle; _controlState?: ControlState; _redrawing?: boolean; _cachedTimelineWidth?: number; _cachedTimelineHeight?: number; _scrubberPlayer?: Animation; _gridOffsetLeft?: number; _originalScrubberTime?: number|null; _originalMousePosition?: number; private constructor() { super(true); this.registerRequiredCSS('animation/animationTimeline.css', {enableLegacyPatching: false}); this.element.classList.add('animations-timeline'); this._gridWrapper = this.contentElement.createChild('div', 'grid-overflow-wrapper'); this._grid = UI.UIUtils.createSVGChild(this._gridWrapper, 'svg', 'animation-timeline-grid'); this._playbackRate = 1; this._allPaused = false; this._createHeader(); this._animationsContainer = this.contentElement.createChild('div', 'animation-timeline-rows'); const timelineHint = this.contentElement.createChild('div', 'animation-timeline-rows-hint'); timelineHint.textContent = i18nString(UIStrings.selectAnEffectAboveToInspectAnd); /** @const */ this._defaultDuration = 100; this._duration = this._defaultDuration; /** @const */ this._timelineControlsWidth = 150; this._nodesMap = new Map(); this._uiAnimations = []; this._groupBuffer = []; this._previewMap = new Map(); this._symbol = Symbol('animationTimeline'); this._animationsMap = new Map(); SDK.SDKModel.TargetManager.instance().addModelListener( SDK.DOMModel.DOMModel, SDK.DOMModel.Events.NodeRemoved, this._nodeRemoved, this); SDK.SDKModel.TargetManager.instance().observeModels(AnimationModel, this); UI.Context.Context.instance().addFlavorChangeListener(SDK.DOMModel.DOMNode, this._nodeChanged, this); } static instance(): AnimationTimeline { if (!animationTimelineInstance) { animationTimelineInstance = new AnimationTimeline(); } return animationTimelineInstance; } wasShown(): void { for (const animationModel of SDK.SDKModel.TargetManager.instance().models(AnimationModel)) { this._addEventListeners(animationModel); } } willHide(): void { for (const animationModel of SDK.SDKModel.TargetManager.instance().models(AnimationModel)) { this._removeEventListeners(animationModel); } if (this._popoverHelper) { this._popoverHelper.hidePopover(); } } modelAdded(animationModel: AnimationModel): void { if (this.isShowing()) { this._addEventListeners(animationModel); } } modelRemoved(animationModel: AnimationModel): void { this._removeEventListeners(animationModel); } _addEventListeners(animationModel: AnimationModel): void { animationModel.ensureEnabled(); animationModel.addEventListener(Events.AnimationGroupStarted, this._animationGroupStarted, this); animationModel.addEventListener(Events.ModelReset, this._reset, this); } _removeEventListeners(animationModel: AnimationModel): void { animationModel.removeEventListener(Events.AnimationGroupStarted, this._animationGroupStarted, this); animationModel.removeEventListener(Events.ModelReset, this._reset, this); } _nodeChanged(): void { for (const nodeUI of this._nodesMap.values()) { nodeUI._nodeChanged(); } } _createScrubber(): HTMLElement { this._timelineScrubber = document.createElement('div'); this._timelineScrubber.classList.add('animation-scrubber'); this._timelineScrubber.classList.add('hidden'); this._timelineScrubberLine = this._timelineScrubber.createChild('div', 'animation-scrubber-line'); this._timelineScrubberLine.createChild('div', 'animation-scrubber-head'); this._timelineScrubber.createChild('div', 'animation-time-overlay'); return this._timelineScrubber; } _createHeader(): HTMLElement { const toolbarContainer = this.contentElement.createChild('div', 'animation-timeline-toolbar-container'); const topToolbar = new UI.Toolbar.Toolbar('animation-timeline-toolbar', toolbarContainer); this._clearButton = new UI.Toolbar.ToolbarButton(i18nString(UIStrings.clearAll), 'largeicon-clear'); this._clearButton.addEventListener(UI.Toolbar.ToolbarButton.Events.Click, this._reset.bind(this)); topToolbar.appendToolbarItem(this._clearButton); topToolbar.appendSeparator(); this._pauseButton = new UI.Toolbar.ToolbarToggle(i18nString(UIStrings.pauseAll), 'largeicon-pause', 'largeicon-resume'); this._pauseButton.addEventListener(UI.Toolbar.ToolbarButton.Events.Click, this._togglePauseAll.bind(this)); topToolbar.appendToolbarItem(this._pauseButton); const playbackRateControl = toolbarContainer.createChild('div', 'animation-playback-rate-control'); playbackRateControl.addEventListener('keydown', this._handlePlaybackRateControlKeyDown.bind(this)); UI.ARIAUtils.markAsListBox(playbackRateControl); UI.ARIAUtils.setAccessibleName(playbackRateControl, i18nString(UIStrings.playbackRates)); /** @type {!Array<!HTMLElement>} */ this._playbackRateButtons = []; for (const playbackRate of GlobalPlaybackRates) { const button = (playbackRateControl.createChild('button', 'animation-playback-rate-button') as HTMLElement); button.textContent = playbackRate ? i18nString(UIStrings.playbackRatePlaceholder, {PH1: playbackRate * 100}) : i18nString(UIStrings.pause); playbackRates.set(button, playbackRate); button.addEventListener('click', this._setPlaybackRate.bind(this, playbackRate)); UI.ARIAUtils.markAsOption(button); UI.Tooltip.Tooltip.install(button, i18nString(UIStrings.setSpeedToS, {PH1: button.textContent})); button.tabIndex = -1; this._playbackRateButtons.push(button); } this._updatePlaybackControls(); this._previewContainer = (this.contentElement.createChild('div', 'animation-timeline-buffer') as HTMLElement); UI.ARIAUtils.markAsListBox(this._previewContainer); UI.ARIAUtils.setAccessibleName(this._previewContainer, i18nString(UIStrings.animationPreviews)); this._popoverHelper = new UI.PopoverHelper.PopoverHelper(this._previewContainer, this._getPopoverRequest.bind(this)); this._popoverHelper.setDisableOnClick(true); this._popoverHelper.setTimeout(0); const emptyBufferHint = this.contentElement.createChild('div', 'animation-timeline-buffer-hint'); emptyBufferHint.textContent = i18nString(UIStrings.listeningForAnimations); const container = this.contentElement.createChild('div', 'animation-timeline-header'); const controls = container.createChild('div', 'animation-controls'); this._currentTime = (controls.createChild('div', 'animation-timeline-current-time monospace') as HTMLElement); const toolbar = new UI.Toolbar.Toolbar('animation-controls-toolbar', controls); this._controlButton = new UI.Toolbar.ToolbarToggle(i18nString(UIStrings.replayTimeline), 'largeicon-replay-animation'); this._controlState = ControlState.Replay; this._controlButton.setToggled(true); this._controlButton.addEventListener(UI.Toolbar.ToolbarButton.Events.Click, this._controlButtonToggle.bind(this)); toolbar.appendToolbarItem(this._controlButton); const gridHeader = container.createChild('div', 'animation-grid-header'); UI.UIUtils.installDragHandle( gridHeader, this._repositionScrubber.bind(this), this._scrubberDragMove.bind(this), this._scrubberDragEnd.bind(this), 'text'); this._gridWrapper.appendChild(this._createScrubber()); if (this._timelineScrubberLine) { UI.UIUtils.installDragHandle( this._timelineScrubberLine, this._scrubberDragStart.bind(this), this._scrubberDragMove.bind(this), this._scrubberDragEnd.bind(this), 'col-resize'); } this._currentTime.textContent = ''; return container; } _handlePlaybackRateControlKeyDown(event: Event): void { const keyboardEvent = (event as KeyboardEvent); switch (keyboardEvent.key) { case 'ArrowLeft': case 'ArrowUp': this._focusNextPlaybackRateButton(event.target, /* focusPrevious */ true); break; case 'ArrowRight': case 'ArrowDown': this._focusNextPlaybackRateButton(event.target); break; } } _focusNextPlaybackRateButton(target: EventTarget|null, focusPrevious?: boolean): void { const button = (target as HTMLElement); const currentIndex = this._playbackRateButtons.indexOf(button); const nextIndex = focusPrevious ? currentIndex - 1 : currentIndex + 1; if (nextIndex < 0 || nextIndex >= this._playbackRateButtons.length) { return; } const nextButton = this._playbackRateButtons[nextIndex]; nextButton.tabIndex = 0; nextButton.focus(); if (target) { (target as HTMLElement).tabIndex = -1; } } _getPopoverRequest(event: Event): UI.PopoverHelper.PopoverRequest|null { const element = (event.target as HTMLElement); if (!element || !element.isDescendant(this._previewContainer)) { return null; } return { box: element.boxInWindow(), show: (popover: UI.GlassPane.GlassPane): Promise<boolean> => { let animGroup; for (const [group, previewUI] of this._previewMap) { if (previewUI.element === element.parentElement) { animGroup = group; } } console.assert(typeof animGroup !== 'undefined'); if (!animGroup) { return Promise.resolve(false); } const screenshots = animGroup.screenshots(); if (!screenshots.length) { return Promise.resolve(false); } let fulfill: (arg0: boolean) => void; const promise = new Promise<boolean>(x => { fulfill = x; }); if (!screenshots[0].complete) { screenshots[0].onload = onFirstScreenshotLoaded.bind(null, screenshots); } else { onFirstScreenshotLoaded(screenshots); } return promise; function onFirstScreenshotLoaded(screenshots: HTMLImageElement[]): void { new AnimationScreenshotPopover(screenshots).show(popover.contentElement); fulfill(true); } }, hide: undefined, }; } _togglePauseAll(): void { this._allPaused = !this._allPaused; if (this._pauseButton) { this._pauseButton.setToggled(this._allPaused); } this._setPlaybackRate(this._playbackRate); if (this._pauseButton) { this._pauseButton.setTitle(this._allPaused ? i18nString(UIStrings.resumeAll) : i18nString(UIStrings.pauseAll)); } } _setPlaybackRate(playbackRate: number): void { this._playbackRate = playbackRate; for (const animationModel of SDK.SDKModel.TargetManager.instance().models(AnimationModel)) { animationModel.setPlaybackRate(this._allPaused ? 0 : this._playbackRate); } Host.userMetrics.actionTaken(Host.UserMetrics.Action.AnimationsPlaybackRateChanged); if (this._scrubberPlayer) { this._scrubberPlayer.playbackRate = this._effectivePlaybackRate(); } this._updatePlaybackControls(); } _updatePlaybackControls(): void { for (const button of this._playbackRateButtons) { const selected = this._playbackRate === playbackRates.get(button); button.classList.toggle('selected', selected); button.tabIndex = selected ? 0 : -1; } } _controlButtonToggle(): void { if (this._controlState === ControlState.Play) { this._togglePause(false); } else if (this._controlState === ControlState.Replay) { this._replay(); } else { this._togglePause(true); } } _updateControlButton(): void { if (!this._controlButton) { return; } this._controlButton.setEnabled(Boolean(this._selectedGroup)); if (this._selectedGroup && this._selectedGroup.paused()) { this._controlState = ControlState.Play; this._controlButton.setToggled(true); this._controlButton.setTitle(i18nString(UIStrings.playTimeline)); this._controlButton.setGlyph('largeicon-play-animation'); } else if ( !this._scrubberPlayer || !this._scrubberPlayer.currentTime || this._scrubberPlayer.currentTime >= this.duration()) { this._controlState = ControlState.Replay; this._controlButton.setToggled(true); this._controlButton.setTitle(i18nString(UIStrings.replayTimeline)); this._controlButton.setGlyph('largeicon-replay-animation'); } else { this._controlState = ControlState.Pause; this._controlButton.setToggled(false); this._controlButton.setTitle(i18nString(UIStrings.pauseTimeline)); this._controlButton.setGlyph('largeicon-pause-animation'); } } _effectivePlaybackRate(): number { return (this._allPaused || (this._selectedGroup && this._selectedGroup.paused())) ? 0 : this._playbackRate; } _togglePause(pause: boolean): void { if (this._scrubberPlayer) { this._scrubberPlayer.playbackRate = this._effectivePlaybackRate(); } if (this._selectedGroup) { this._selectedGroup.togglePause(pause); const preview = this._previewMap.get(this._selectedGroup); if (preview) { preview.element.classList.toggle('paused', pause); } } this._updateControlButton(); } _replay(): void { if (!this._selectedGroup) { return; } this._selectedGroup.seekTo(0); this._animateTime(0); this._updateControlButton(); } duration(): number { return this._duration; } setDuration(duration: number): void { this._duration = duration; this.scheduleRedraw(); } _clearTimeline(): void { this._uiAnimations = []; this._nodesMap.clear(); this._animationsMap.clear(); this._animationsContainer.removeChildren(); this._duration = this._defaultDuration; this._timelineScrubber.classList.add('hidden'); this._selectedGroup = null; if (this._scrubberPlayer) { this._scrubberPlayer.cancel(); } delete this._scrubberPlayer; this._currentTime.textContent = ''; this._updateControlButton(); } _reset(): void { this._clearTimeline(); if (this._allPaused) { this._togglePauseAll(); } else { this._setPlaybackRate(this._playbackRate); } for (const group of this._groupBuffer) { group.release(); } this._groupBuffer = []; this._previewMap.clear(); this._previewContainer.removeChildren(); this._popoverHelper.hidePopover(); this._renderGrid(); } _animationGroupStarted(event: Common.EventTarget.EventTargetEvent): void { this._addAnimationGroup((event.data as AnimationGroup)); } _addAnimationGroup(group: AnimationGroup): void { function startTimeComparator(left: AnimationGroup, right: AnimationGroup): 0|1|- 1 { if (left.startTime() === right.startTime()) { return 0; } return left.startTime() > right.startTime() ? 1 : -1; } const previewGroup = this._previewMap.get(group); if (previewGroup) { if (this._selectedGroup === group) { this._syncScrubber(); } else { previewGroup.replay(); } return; } this._groupBuffer.sort(startTimeComparator); // Discard oldest groups from buffer if necessary const groupsToDiscard = []; const bufferSize = this.width() / 50; while (this._groupBuffer.length > bufferSize) { const toDiscard = this._groupBuffer.splice(this._groupBuffer[0] === this._selectedGroup ? 1 : 0, 1); groupsToDiscard.push(toDiscard[0]); } for (const g of groupsToDiscard) { const discardGroup = this._previewMap.get(g); if (!discardGroup) { continue; } discardGroup.element.remove(); this._previewMap.delete(g); g.release(); } // Generate preview const preview = new AnimationGroupPreviewUI(group); this._groupBuffer.push(group); this._previewMap.set(group, preview); this._previewContainer.appendChild(preview.element); preview.removeButton().addEventListener('click', this._removeAnimationGroup.bind(this, group)); preview.element.addEventListener('click', this._selectAnimationGroup.bind(this, group)); preview.element.addEventListener('keydown', this._handleAnimationGroupKeyDown.bind(this, group)); UI.ARIAUtils.setAccessibleName( preview.element, i18nString(UIStrings.animationPreviewS, {PH1: this._groupBuffer.indexOf(group) + 1})); UI.ARIAUtils.markAsOption(preview.element); if (this._previewMap.size === 1) { const preview = this._previewMap.get(this._groupBuffer[0]); if (preview) { preview.element.tabIndex = 0; } } } _handleAnimationGroupKeyDown(group: AnimationGroup, event: KeyboardEvent): void { switch (event.key) { case ' ': case 'Enter': this._selectAnimationGroup(group); break; case 'Backspace': case 'Delete': this._removeAnimationGroup(group, event); break; case 'ArrowLeft': case 'ArrowUp': this._focusNextGroup(group, /* target */ event.target, /* focusPrevious */ true); break; case 'ArrowRight': case 'ArrowDown': this._focusNextGroup(group, /* target */ event.target); } } _focusNextGroup(group: AnimationGroup, target: EventTarget|null, focusPrevious?: boolean): void { const currentGroupIndex = this._groupBuffer.indexOf(group); const nextIndex = focusPrevious ? currentGroupIndex - 1 : currentGroupIndex + 1; if (nextIndex < 0 || nextIndex >= this._groupBuffer.length) { return; } const preview = this._previewMap.get(this._groupBuffer[nextIndex]); if (preview) { preview.element.tabIndex = 0; preview.element.focus(); } if (target) { (target as HTMLElement).tabIndex = -1; } } _removeAnimationGroup(group: AnimationGroup, event: Event): void { const currentGroupIndex = this._groupBuffer.indexOf(group); Platform.ArrayUtilities.removeElement(this._groupBuffer, group); const previewGroup = this._previewMap.get(group); if (previewGroup) { previewGroup.element.remove(); } this._previewMap.delete(group); group.release(); event.consume(true); if (this._selectedGroup === group) { this._clearTimeline(); this._renderGrid(); } const groupLength = this._groupBuffer.length; if (groupLength === 0) { (this._clearButton.element as HTMLElement).focus(); return; } const nextGroup = currentGroupIndex >= this._groupBuffer.length ? this._previewMap.get(this._groupBuffer[this._groupBuffer.length - 1]) : this._previewMap.get(this._groupBuffer[currentGroupIndex]); if (nextGroup) { nextGroup.element.tabIndex = 0; nextGroup.element.focus(); } } _selectAnimationGroup(group: AnimationGroup): void { function applySelectionClass(this: AnimationTimeline, ui: AnimationGroupPreviewUI, group: AnimationGroup): void { ui.element.classList.toggle('selected', this._selectedGroup === group); } if (this._selectedGroup === group) { this._togglePause(false); this._replay(); return; } this._clearTimeline(); this._selectedGroup = group; this._previewMap.forEach(applySelectionClass, this); this.setDuration(Math.max(500, group.finiteDuration() + 100)); for (const anim of group.animations()) { this._addAnimation(anim); } this.scheduleRedraw(); this._timelineScrubber.classList.remove('hidden'); this._togglePause(false); this._replay(); } _addAnimation(animation: AnimationImpl): void { function nodeResolved(this: AnimationTimeline, node: SDK.DOMModel.DOMNode|null): void { uiAnimation.setNode(node); if (node && nodeUI) { nodeUI.nodeResolved(node); nodeUIsByNode.set(node, nodeUI); } } let nodeUI = this._nodesMap.get(animation.source().backendNodeId()); if (!nodeUI) { nodeUI = new NodeUI(animation.source()); this._animationsContainer.appendChild(nodeUI.element); this._nodesMap.set(animation.source().backendNodeId(), nodeUI); } const nodeRow = nodeUI.createNewRow(); const uiAnimation = new AnimationUI(animation, this, nodeRow); animation.source().deferredNode().resolve(nodeResolved.bind(this)); this._uiAnimations.push(uiAnimation); this._animationsMap.set(animation.id(), animation); } _nodeRemoved(event: Common.EventTarget.EventTargetEvent): void { const node = event.data.node; const nodeUI = nodeUIsByNode.get(node); if (nodeUI) { nodeUI.nodeRemoved(); } } _renderGrid(): void { /** @const */ const gridSize = 250; const gridWidth = (this.width() + 10).toString(); const gridHeight = ((this._cachedTimelineHeight || 0) + 30).toString(); this._gridWrapper.style.width = gridWidth + 'px'; this._gridWrapper.style.height = gridHeight.toString() + 'px'; this._grid.setAttribute('width', gridWidth); this._grid.setAttribute('height', gridHeight.toString()); this._grid.setAttribute('shape-rendering', 'crispEdges'); this._grid.removeChildren(); let lastDraw: number|undefined = undefined; for (let time = 0; time < this.duration(); time += gridSize) { const line = UI.UIUtils.createSVGChild(this._grid, 'rect', 'animation-timeline-grid-line'); line.setAttribute('x', (time * this.pixelMsRatio() + 10).toString()); line.setAttribute('y', '23'); line.setAttribute('height', '100%'); line.setAttribute('width', '1'); } for (let time = 0; time < this.duration(); time += gridSize) { const gridWidth = time * this.pixelMsRatio(); if (lastDraw === undefined || gridWidth - lastDraw > 50) { lastDraw = gridWidth; const label = UI.UIUtils.createSVGChild(this._grid, 'text', 'animation-timeline-grid-label'); label.textContent = Number.millisToString(time); label.setAttribute('x', (gridWidth + 10).toString()); label.setAttribute('y', '16'); } } } scheduleRedraw(): void { this._renderQueue = []; for (const ui of this._uiAnimations) { this._renderQueue.push(ui); } if (this._redrawing) { return; } this._redrawing = true; this._renderGrid(); this._animationsContainer.window().requestAnimationFrame(this._render.bind(this)); } _render(timestamp?: number): void { while (this._renderQueue.length && (!timestamp || window.performance.now() - timestamp < 50)) { const animationUI = this._renderQueue.shift(); if (animationUI) { animationUI.redraw(); } } if (this._renderQueue.length) { this._animationsContainer.window().requestAnimationFrame(this._render.bind(this)); } else { delete this._redrawing; } } onResize(): void { this._cachedTimelineWidth = Math.max(0, this._animationsContainer.offsetWidth - this._timelineControlsWidth) || 0; this._cachedTimelineHeight = this._animationsContainer.offsetHeight; this.scheduleRedraw(); if (this._scrubberPlayer) { this._syncScrubber(); } delete this._gridOffsetLeft; } width(): number { return this._cachedTimelineWidth || 0; } _resizeWindow(animation: AnimationImpl): boolean { let resized = false; // This shows at most 3 iterations const duration = animation.source().duration() * Math.min(2, animation.source().iterations()); const requiredDuration = animation.source().delay() + duration + animation.source().endDelay(); if (requiredDuration > this._duration) { resized = true; this._duration = requiredDuration + 200; } return resized; } _syncScrubber(): void { if (!this._selectedGroup) { return; } this._selectedGroup.currentTimePromise() .then(this._animateTime.bind(this)) .then(this._updateControlButton.bind(this)); } _animateTime(currentTime: number): void { if (this._scrubberPlayer) { this._scrubberPlayer.cancel(); } this._scrubberPlayer = this._timelineScrubber.animate( [{transform: 'translateX(0px)'}, {transform: 'translateX(' + this.width() + 'px)'}], {duration: this.duration(), fill: 'forwards'}); this._scrubberPlayer.playbackRate = this._effectivePlaybackRate(); this._scrubberPlayer.onfinish = this._updateControlButton.bind(this); this._scrubberPlayer.currentTime = currentTime; this.element.window().requestAnimationFrame(this._updateScrubber.bind(this)); } pixelMsRatio(): number { return this.width() / this.duration() || 0; } _updateScrubber(_timestamp: number): void { if (!this._scrubberPlayer) { return; } this._currentTime.textContent = Number.millisToString(this._scrubberPlayer.currentTime || 0); if (this._scrubberPlayer.playState.toString() === 'pending' || this._scrubberPlayer.playState === 'running') { this.element.window().requestAnimationFrame(this._updateScrubber.bind(this)); } else if (this._scrubberPlayer.playState === 'finished') { this._currentTime.textContent = ''; } } _repositionScrubber(event: Event): boolean { if (!this._selectedGroup) { return false; } // Seek to current mouse position. if (!this._gridOffsetLeft) { this._gridOffsetLeft = this._grid.totalOffsetLeft() + 10; } const {x} = (event as any); // eslint-disable-line @typescript-eslint/no-explicit-any const seekTime = Math.max(0, x - this._gridOffsetLeft) / this.pixelMsRatio(); this._selectedGroup.seekTo(seekTime); this._togglePause(true); this._animateTime(seekTime); // Interface with scrubber drag. this._originalScrubberTime = seekTime; this._originalMousePosition = x; return true; } _scrubberDragStart(event: Event): boolean { if (!this._scrubberPlayer || !this._selectedGroup) { return false; } this._originalScrubberTime = this._scrubberPlayer.currentTime; this._timelineScrubber.classList.remove('animation-timeline-end'); this._scrubberPlayer.pause(); const {x} = (event as any); // eslint-disable-line @typescript-eslint/no-explicit-any this._originalMousePosition = x; this._togglePause(true); return true; } _scrubberDragMove(event: Event): void { const {x} = (event as any); // eslint-disable-line @typescript-eslint/no-explicit-any const delta = x - (this._originalMousePosition || 0); const currentTime = Math.max(0, Math.min((this._originalScrubberTime || 0) + delta / this.pixelMsRatio(), this.duration())); if (this._scrubberPlayer) { this._scrubberPlayer.currentTime = currentTime; } this._currentTime.textContent = Number.millisToString(Math.round(currentTime)); if (this._selectedGroup) { this._selectedGroup.seekTo(currentTime); } } _scrubberDragEnd(_event: Event): void { if (this._scrubberPlayer) { const currentTime = Math.max(0, this._scrubberPlayer.currentTime || 0); this._scrubberPlayer.play(); this._scrubberPlayer.currentTime = currentTime; } this._currentTime.window().requestAnimationFrame(this._updateScrubber.bind(this)); } } export const GlobalPlaybackRates = [1, 0.25, 0.1]; const enum ControlState { Play = 'play-outline', Replay = 'replay-outline', Pause = 'pause-outline', } export class NodeUI { element: HTMLDivElement; _description: HTMLElement; _timelineElement: HTMLElement; _node?: SDK.DOMModel.DOMNode|null; constructor(_animationEffect: AnimationEffect) { this.element = document.createElement('div'); this.element.classList.add('animation-node-row'); this._description = this.element.createChild('div', 'animation-node-description'); this._description.tabIndex = 0; this._timelineElement = this.element.createChild('div', 'animation-node-timeline'); UI.ARIAUtils.markAsApplication(this._timelineElement); } nodeResolved(node: SDK.DOMModel.DOMNode|null): void { if (!node) { UI.UIUtils.createTextChild(this._description, '<node>'); return; } this._node = node; this._nodeChanged(); Common.Linkifier.Linkifier.linkify(node).then(link => this._description.appendChild(link)); if (!node.ownerDocument) { this.nodeRemoved(); } } createNewRow(): Element { return this._timelineElement.createChild('div', 'animation-timeline-row'); } nodeRemoved(): void { this.element.classList.add('animation-node-removed'); this._node = null; } _nodeChanged(): void { let animationNodeSelected = false; if (this._node) { animationNodeSelected = (this._node === UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode)); } this.element.classList.toggle('animation-node-selected', animationNodeSelected); } } export class StepTimingFunction { steps: number; stepAtPosition: string; constructor(steps: number, stepAtPosition: string) { this.steps = steps; this.stepAtPosition = stepAtPosition; } static parse(text: string): StepTimingFunction|null { let match = text.match(/^steps\((\d+), (start|middle)\)$/); if (match) { return new StepTimingFunction(parseInt(match[1], 10), match[2]); } match = text.match(/^steps\((\d+)\)$/); if (match) { return new StepTimingFunction(parseInt(match[1], 10), 'end'); } return null; } }