| // Copyright 2014 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as Common from '../../core/common/common.js'; |
| import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js'; |
| import * as Protocol from '../../generated/protocol.js'; |
| |
| import {DeferredDOMNode, type DOMNode} from './DOMModel.js'; |
| import {RemoteObject} from './RemoteObject.js'; |
| import {Events as ResourceTreeModelEvents, ResourceTreeModel} from './ResourceTreeModel.js'; |
| import {Events as RuntimeModelEvents, type EventTypes as RuntimeModelEventTypes, RuntimeModel} from './RuntimeModel.js'; |
| import {SDKModel} from './SDKModel.js'; |
| import {Capability, type Target} from './Target.js'; |
| |
| const DEVTOOLS_ANIMATIONS_WORLD_NAME = 'devtools_animations'; |
| const REPORT_SCROLL_POSITION_BINDING_NAME = '__devtools_report_scroll_position__'; |
| |
| const getScrollListenerNameInPage = (id: number): string => `__devtools_scroll_listener_${id}__`; |
| |
| type ScrollListener = (param: {scrollLeft: number, scrollTop: number}) => void; |
| type BindingListener = |
| (ev: Common.EventTarget.EventTargetEvent<Protocol.Runtime.BindingCalledEvent, RuntimeModelEventTypes>) => void; |
| |
| async function resolveToObjectInWorld(domNode: DOMNode, worldName: string): Promise<RemoteObject|null> { |
| const resourceTreeModel = domNode.domModel().target().model(ResourceTreeModel) as ResourceTreeModel; |
| const pageAgent = domNode.domModel().target().pageAgent(); |
| for (const frame of resourceTreeModel.frames()) { |
| // This returns previously created world if it exists for the frame. |
| const {executionContextId} = await pageAgent.invoke_createIsolatedWorld({frameId: frame.id, worldName}); |
| const object = await domNode.resolveToObject(undefined, executionContextId); |
| if (object) { |
| return object; |
| } |
| } |
| return null; |
| } |
| |
| /** |
| * Provides an extension over `DOMNode` that gives it additional |
| * capabilities for animation debugging, mainly: |
| * - getting a node's scroll information (scroll offsets and scroll range). |
| * - updating a node's scroll offset. |
| * - tracking the node's scroll offsets with event listeners. |
| * |
| * It works by running functions on the target page, see `DOMNode`s `callFunction` method |
| * for more details on how a function is called on the target page. |
| * |
| * For listening to events on the target page and getting notified on the devtools frontend |
| * side, we're adding a binding to the page `__devtools_report_scroll_position__` in a world `devtools_animation` |
| * we've created. Then, we're setting scroll listeners of the `node` in the same world which calls the binding |
| * itself with the scroll offsets. |
| */ |
| export class AnimationDOMNode { |
| #domNode: DOMNode; |
| #scrollListenersById = new Map<number, ScrollListener>(); |
| #scrollBindingListener?: BindingListener; |
| |
| static lastAddedListenerId = 0; |
| |
| constructor(domNode: DOMNode) { |
| this.#domNode = domNode; |
| } |
| |
| async #addReportScrollPositionBinding(): Promise<void> { |
| // The binding is already added so we don't need to add it again. |
| if (this.#scrollBindingListener) { |
| return; |
| } |
| |
| this.#scrollBindingListener = ev => { |
| const {name, payload} = ev.data; |
| if (name !== REPORT_SCROLL_POSITION_BINDING_NAME) { |
| return; |
| } |
| |
| const {scrollTop, scrollLeft, id} = JSON.parse(payload) as {scrollTop: number, scrollLeft: number, id: number}; |
| const scrollListener = this.#scrollListenersById.get(id); |
| if (!scrollListener) { |
| return; |
| } |
| |
| scrollListener({scrollTop, scrollLeft}); |
| }; |
| |
| const runtimeModel = this.#domNode.domModel().target().model(RuntimeModel) as RuntimeModel; |
| await runtimeModel.addBinding({ |
| name: REPORT_SCROLL_POSITION_BINDING_NAME, |
| executionContextName: DEVTOOLS_ANIMATIONS_WORLD_NAME, |
| }); |
| runtimeModel.addEventListener(RuntimeModelEvents.BindingCalled, this.#scrollBindingListener); |
| } |
| |
| async #removeReportScrollPositionBinding(): Promise<void> { |
| // There isn't any binding added yet. |
| if (!this.#scrollBindingListener) { |
| return; |
| } |
| |
| const runtimeModel = this.#domNode.domModel().target().model(RuntimeModel) as RuntimeModel; |
| await runtimeModel.removeBinding({ |
| name: REPORT_SCROLL_POSITION_BINDING_NAME, |
| }); |
| runtimeModel.removeEventListener(RuntimeModelEvents.BindingCalled, this.#scrollBindingListener); |
| this.#scrollBindingListener = undefined; |
| } |
| |
| async addScrollEventListener(onScroll: ({scrollLeft, scrollTop}: {scrollLeft: number, scrollTop: number}) => void): |
| Promise<number|null> { |
| AnimationDOMNode.lastAddedListenerId++; |
| const id = AnimationDOMNode.lastAddedListenerId; |
| this.#scrollListenersById.set(id, onScroll); |
| // Add the binding for reporting scroll events from the page if it doesn't exist. |
| if (!this.#scrollBindingListener) { |
| await this.#addReportScrollPositionBinding(); |
| } |
| |
| const object = await resolveToObjectInWorld(this.#domNode, DEVTOOLS_ANIMATIONS_WORLD_NAME); |
| if (!object) { |
| return null; |
| } |
| |
| await object.callFunction(scrollListenerInPage, [ |
| id, |
| REPORT_SCROLL_POSITION_BINDING_NAME, |
| getScrollListenerNameInPage(id), |
| ].map(arg => RemoteObject.toCallArgument(arg))); |
| object.release(); |
| return id; |
| |
| function scrollListenerInPage( |
| this: HTMLElement|Document, id: number, reportScrollPositionBindingName: string, |
| scrollListenerNameInPage: string): void { |
| if ('scrollingElement' in this && !this.scrollingElement) { |
| return; |
| } |
| |
| const scrollingElement = ('scrollingElement' in this ? this.scrollingElement : this) as HTMLElement; |
| // @ts-expect-error We're setting a custom field on `Element` or `Document` for retaining the function on the page. |
| this[scrollListenerNameInPage] = () => { |
| // @ts-expect-error `reportScrollPosition` binding is injected to the page before calling the function. |
| globalThis[reportScrollPositionBindingName]( |
| JSON.stringify({scrollTop: scrollingElement.scrollTop, scrollLeft: scrollingElement.scrollLeft, id})); |
| }; |
| |
| // @ts-expect-error We've already defined the function used below. |
| this.addEventListener('scroll', this[scrollListenerNameInPage], true); |
| } |
| } |
| |
| async removeScrollEventListener(id: number): Promise<void> { |
| const object = await resolveToObjectInWorld(this.#domNode, DEVTOOLS_ANIMATIONS_WORLD_NAME); |
| if (!object) { |
| return; |
| } |
| |
| await object.callFunction( |
| removeScrollListenerInPage, [getScrollListenerNameInPage(id)].map(arg => RemoteObject.toCallArgument(arg))); |
| object.release(); |
| |
| this.#scrollListenersById.delete(id); |
| // There aren't any scroll listeners remained on the page |
| // so we remove the binding. |
| if (this.#scrollListenersById.size === 0) { |
| await this.#removeReportScrollPositionBinding(); |
| } |
| |
| function removeScrollListenerInPage(this: HTMLElement|Document, scrollListenerNameInPage: string): void { |
| // @ts-expect-error We've already set this custom field while adding scroll listener. |
| this.removeEventListener('scroll', this[scrollListenerNameInPage]); |
| // @ts-expect-error We've already set this custom field while adding scroll listener. |
| delete this[scrollListenerNameInPage]; |
| } |
| } |
| |
| async scrollTop(): Promise<number|null> { |
| return await this.#domNode.callFunction(scrollTopInPage).then(res => res?.value ?? null); |
| |
| function scrollTopInPage(this: Element|Document): number { |
| if ('scrollingElement' in this) { |
| if (!this.scrollingElement) { |
| return 0; |
| } |
| |
| return this.scrollingElement.scrollTop; |
| } |
| return this.scrollTop; |
| } |
| } |
| |
| async scrollLeft(): Promise<number|null> { |
| return await this.#domNode.callFunction(scrollLeftInPage).then(res => res?.value ?? null); |
| |
| function scrollLeftInPage(this: Element|Document): number { |
| if ('scrollingElement' in this) { |
| if (!this.scrollingElement) { |
| return 0; |
| } |
| |
| return this.scrollingElement.scrollLeft; |
| } |
| return this.scrollLeft; |
| } |
| } |
| |
| async setScrollTop(offset: number): Promise<void> { |
| await this.#domNode.callFunction(setScrollTopInPage, [offset]); |
| |
| function setScrollTopInPage(this: Element|Document, offsetInPage: number): void { |
| if ('scrollingElement' in this) { |
| if (!this.scrollingElement) { |
| return; |
| } |
| |
| this.scrollingElement.scrollTop = offsetInPage; |
| } else { |
| this.scrollTop = offsetInPage; |
| } |
| } |
| } |
| |
| async setScrollLeft(offset: number): Promise<void> { |
| await this.#domNode.callFunction(setScrollLeftInPage, [offset]); |
| |
| function setScrollLeftInPage(this: Element|Document, offsetInPage: number): void { |
| if ('scrollingElement' in this) { |
| if (!this.scrollingElement) { |
| return; |
| } |
| |
| this.scrollingElement.scrollLeft = offsetInPage; |
| } else { |
| this.scrollLeft = offsetInPage; |
| } |
| } |
| } |
| |
| async verticalScrollRange(): Promise<number|null> { |
| return await this.#domNode.callFunction(verticalScrollRangeInPage).then(res => res?.value ?? null); |
| |
| function verticalScrollRangeInPage(this: Element|Document): number { |
| if ('scrollingElement' in this) { |
| if (!this.scrollingElement) { |
| return 0; |
| } |
| |
| return this.scrollingElement.scrollHeight - this.scrollingElement.clientHeight; |
| } |
| |
| return this.scrollHeight - this.clientHeight; |
| } |
| } |
| |
| async horizontalScrollRange(): Promise<number|null> { |
| return await this.#domNode.callFunction(horizontalScrollRangeInPage).then(res => res?.value ?? null); |
| |
| function horizontalScrollRangeInPage(this: Element|Document): number { |
| if ('scrollingElement' in this) { |
| if (!this.scrollingElement) { |
| return 0; |
| } |
| |
| return this.scrollingElement.scrollWidth - this.scrollingElement.clientWidth; |
| } |
| |
| return this.scrollWidth - this.clientWidth; |
| } |
| } |
| } |
| |
| function shouldGroupAnimations(firstAnimation: AnimationImpl, anim: AnimationImpl): boolean { |
| const firstAnimationTimeline = firstAnimation.viewOrScrollTimeline(); |
| const animationTimeline = anim.viewOrScrollTimeline(); |
| if (firstAnimationTimeline) { |
| // This is a SDA group so check whether the animation's |
| // scroll container and scroll axis is the same with the first animation. |
| return Boolean( |
| animationTimeline && firstAnimationTimeline.sourceNodeId === animationTimeline.sourceNodeId && |
| firstAnimationTimeline.axis === animationTimeline.axis); |
| } |
| // This is a non-SDA group so check whether the coming animation |
| // is a time based one too and if so, compare their start times. |
| return !animationTimeline && firstAnimation.startTime() === anim.startTime(); |
| } |
| |
| export class AnimationModel extends SDKModel<EventTypes> { |
| readonly runtimeModel: RuntimeModel; |
| readonly agent: ProtocolProxyApi.AnimationApi; |
| #animationsById = new Map<string, AnimationImpl>(); |
| readonly animationGroups = new Map<string, AnimationGroup>(); |
| #pendingAnimations = new Set<string>(); |
| playbackRate = 1; |
| #flushPendingAnimations: () => void; |
| |
| constructor(target: Target) { |
| super(target); |
| this.runtimeModel = (target.model(RuntimeModel) as RuntimeModel); |
| this.agent = target.animationAgent(); |
| target.registerAnimationDispatcher(new AnimationDispatcher(this)); |
| |
| if (!target.suspended()) { |
| void this.agent.invoke_enable(); |
| } |
| |
| const resourceTreeModel = (target.model(ResourceTreeModel) as ResourceTreeModel); |
| resourceTreeModel.addEventListener(ResourceTreeModelEvents.PrimaryPageChanged, this.reset, this); |
| |
| this.#flushPendingAnimations = Common.Debouncer.debounce(() => { |
| while (this.#pendingAnimations.size) { |
| this.matchExistingGroups(this.createGroupFromPendingAnimations()); |
| } |
| }, 100); |
| } |
| |
| private reset(): void { |
| this.#animationsById.clear(); |
| this.animationGroups.clear(); |
| this.#pendingAnimations.clear(); |
| this.dispatchEventToListeners(Events.ModelReset); |
| } |
| |
| async devicePixelRatio(): Promise<number> { |
| const evaluateResult = await this.target().runtimeAgent().invoke_evaluate({expression: 'window.devicePixelRatio'}); |
| if (evaluateResult?.result.type === 'number') { |
| return evaluateResult?.result.value as number ?? 1; |
| } |
| |
| return 1; |
| } |
| |
| async getAnimationGroupForAnimation(name: string, nodeId: Protocol.DOM.NodeId): Promise<AnimationGroup|null> { |
| for (const animationGroup of this.animationGroups.values()) { |
| for (const animation of animationGroup.animations()) { |
| if (animation.name() === name) { |
| const animationNode = await animation.source().node(); |
| if (animationNode?.id === nodeId) { |
| return animationGroup; |
| } |
| } |
| } |
| } |
| |
| return null; |
| } |
| |
| animationCanceled(id: string): void { |
| this.#pendingAnimations.delete(id); |
| } |
| |
| async animationUpdated(payload: Protocol.Animation.Animation): Promise<void> { |
| let foundAnimationGroup: AnimationGroup|undefined; |
| let foundAnimation: AnimationImpl|undefined; |
| for (const animationGroup of this.animationGroups.values()) { |
| foundAnimation = animationGroup.animations().find(animation => animation.id() === payload.id); |
| if (foundAnimation) { |
| foundAnimationGroup = animationGroup; |
| break; |
| } |
| } |
| |
| if (!foundAnimation || !foundAnimationGroup) { |
| return; |
| } |
| |
| await foundAnimation.setPayload(payload); |
| this.dispatchEventToListeners(Events.AnimationGroupUpdated, foundAnimationGroup); |
| } |
| |
| async animationStarted(payload: Protocol.Animation.Animation): Promise<void> { |
| // We are not interested in animations without effect or target. |
| if (!payload.source?.backendNodeId) { |
| return; |
| } |
| |
| const animation = await AnimationImpl.parsePayload(this, payload); |
| // Ignore Web Animations custom effects & groups. |
| const keyframesRule = animation.source().keyframesRule(); |
| if (animation.type() === 'WebAnimation' && keyframesRule?.keyframes().length === 0) { |
| this.#pendingAnimations.delete(animation.id()); |
| } else { |
| this.#animationsById.set(animation.id(), animation); |
| this.#pendingAnimations.add(animation.id()); |
| } |
| |
| this.#flushPendingAnimations(); |
| } |
| |
| private matchExistingGroups(incomingGroup: AnimationGroup): boolean { |
| let matchedGroup: AnimationGroup|null = null; |
| for (const group of this.animationGroups.values()) { |
| if (group.matches(incomingGroup)) { |
| matchedGroup = group; |
| group.rebaseTo(incomingGroup); |
| break; |
| } |
| |
| if (group.shouldInclude(incomingGroup)) { |
| matchedGroup = group; |
| group.appendAnimations(incomingGroup.animations()); |
| break; |
| } |
| } |
| |
| if (!matchedGroup) { |
| this.animationGroups.set(incomingGroup.id(), incomingGroup); |
| this.dispatchEventToListeners(Events.AnimationGroupStarted, incomingGroup); |
| } else { |
| this.dispatchEventToListeners(Events.AnimationGroupUpdated, matchedGroup); |
| } |
| return Boolean(matchedGroup); |
| } |
| |
| private createGroupFromPendingAnimations(): AnimationGroup { |
| console.assert(this.#pendingAnimations.size > 0); |
| const firstAnimationId = this.#pendingAnimations.values().next().value as string; |
| this.#pendingAnimations.delete(firstAnimationId); |
| |
| const firstAnimation = this.#animationsById.get(firstAnimationId); |
| if (!firstAnimation) { |
| throw new Error('Unable to locate first animation'); |
| } |
| |
| const groupedAnimations = [firstAnimation]; |
| const remainingAnimations = new Set<string>(); |
| |
| for (const id of this.#pendingAnimations) { |
| const anim = this.#animationsById.get(id) as AnimationImpl; |
| if (shouldGroupAnimations(firstAnimation, anim)) { |
| groupedAnimations.push(anim); |
| } else { |
| remainingAnimations.add(id); |
| } |
| } |
| |
| this.#pendingAnimations = remainingAnimations; |
| // Show the first starting animation at the top of the animations of the animation group. |
| groupedAnimations.sort((anim1, anim2) => anim1.startTime() - anim2.startTime()); |
| return new AnimationGroup(this, firstAnimationId, groupedAnimations); |
| } |
| |
| setPlaybackRate(playbackRate: number): void { |
| this.playbackRate = playbackRate; |
| void this.agent.invoke_setPlaybackRate({playbackRate}); |
| } |
| |
| releaseAnimations(animations: string[]): void { |
| void this.agent.invoke_releaseAnimations({animations}); |
| } |
| |
| override async suspendModel(): Promise<void> { |
| await this.agent.invoke_disable().then(() => this.reset()); |
| } |
| |
| override async resumeModel(): Promise<void> { |
| await this.agent.invoke_enable(); |
| } |
| } |
| |
| export enum Events { |
| /* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */ |
| AnimationGroupStarted = 'AnimationGroupStarted', |
| AnimationGroupUpdated = 'AnimationGroupUpdated', |
| ModelReset = 'ModelReset', |
| /* eslint-enable @typescript-eslint/naming-convention */ |
| } |
| |
| export interface EventTypes { |
| [Events.AnimationGroupStarted]: AnimationGroup; |
| [Events.AnimationGroupUpdated]: AnimationGroup; |
| [Events.ModelReset]: void; |
| } |
| |
| export class AnimationImpl { |
| readonly #animationModel: AnimationModel; |
| #payload!: Protocol.Animation |
| .Animation; // Assertion is safe because only way to create `AnimationImpl` is to use `parsePayload` which calls `setPayload` and sets the value. |
| #source!: |
| AnimationEffect; // Assertion is safe because only way to create `AnimationImpl` is to use `parsePayload` which calls `setPayload` and sets the value. |
| #playState?: string; |
| |
| private constructor(animationModel: AnimationModel) { |
| this.#animationModel = animationModel; |
| } |
| |
| static async parsePayload(animationModel: AnimationModel, payload: Protocol.Animation.Animation): |
| Promise<AnimationImpl> { |
| const animation = new AnimationImpl(animationModel); |
| await animation.setPayload(payload); |
| return animation; |
| } |
| |
| async setPayload(payload: Protocol.Animation.Animation): Promise<void> { |
| // TODO(b/40929569): Remove normalizing by devicePixelRatio after the attached bug is resolved. |
| if (payload.viewOrScrollTimeline) { |
| const devicePixelRatio = await this.#animationModel.devicePixelRatio(); |
| if (payload.viewOrScrollTimeline.startOffset) { |
| payload.viewOrScrollTimeline.startOffset /= devicePixelRatio; |
| } |
| |
| if (payload.viewOrScrollTimeline.endOffset) { |
| payload.viewOrScrollTimeline.endOffset /= devicePixelRatio; |
| } |
| } |
| |
| this.#payload = payload; |
| if (this.#source && payload.source) { |
| this.#source.setPayload(payload.source); |
| } else if (!this.#source && payload.source) { |
| this.#source = new AnimationEffect(this.#animationModel, payload.source); |
| } |
| } |
| |
| // `startTime` and `duration` is represented as the |
| // percentage of the view timeline range that starts at `startOffset`px |
| // from the scroll container and ends at `endOffset`px of the scroll container. |
| // This takes a percentage of the timeline range and returns the absolute |
| // pixels values as a scroll offset of the scroll container. |
| private percentageToPixels(percentage: number, viewOrScrollTimeline: Protocol.Animation.ViewOrScrollTimeline): |
| number { |
| const {startOffset, endOffset} = viewOrScrollTimeline; |
| if (startOffset === undefined || endOffset === undefined) { |
| // We don't expect this situation to occur since after an animation is started |
| // we expect the scroll offsets to be resolved and provided correctly. If `startOffset` |
| // or `endOffset` is not provided in a viewOrScrollTimeline; we can assume that there is a bug here |
| // so it's fine to throw an error. |
| throw new Error('startOffset or endOffset does not exist in viewOrScrollTimeline'); |
| } |
| |
| return (endOffset - startOffset) * (percentage / 100); |
| } |
| |
| viewOrScrollTimeline(): Protocol.Animation.ViewOrScrollTimeline|undefined { |
| return this.#payload.viewOrScrollTimeline; |
| } |
| |
| id(): string { |
| return this.#payload.id; |
| } |
| |
| name(): string { |
| return this.#payload.name; |
| } |
| |
| paused(): boolean { |
| return this.#payload.pausedState; |
| } |
| |
| playState(): string { |
| return this.#playState || this.#payload.playState; |
| } |
| |
| playbackRate(): number { |
| return this.#payload.playbackRate; |
| } |
| |
| // For scroll driven animations, it returns the pixel offset in the scroll container |
| // For time animations, it returns milliseconds. |
| startTime(): number { |
| const viewOrScrollTimeline = this.viewOrScrollTimeline(); |
| if (viewOrScrollTimeline) { |
| return this.percentageToPixels( |
| this.playbackRate() > 0 ? this.#payload.startTime : 100 - this.#payload.startTime, |
| viewOrScrollTimeline) + |
| (this.viewOrScrollTimeline()?.startOffset ?? 0); |
| } |
| |
| return this.#payload.startTime; |
| } |
| |
| // For scroll driven animations, it returns the duration in pixels (i.e. after how many pixels of scroll the animation is going to end) |
| // For time animations, it returns milliseconds. |
| iterationDuration(): number { |
| const viewOrScrollTimeline = this.viewOrScrollTimeline(); |
| if (viewOrScrollTimeline) { |
| return this.percentageToPixels(this.source().duration(), viewOrScrollTimeline); |
| } |
| |
| return this.source().duration(); |
| } |
| |
| // For scroll driven animations, it returns the duration in pixels (i.e. after how many pixels of scroll the animation is going to end) |
| // For time animations, it returns milliseconds. |
| endTime(): number { |
| if (!this.source().iterations) { |
| return Infinity; |
| } |
| |
| if (this.viewOrScrollTimeline()) { |
| return this.startTime() + this.iterationDuration() * this.source().iterations(); |
| } |
| |
| return this.startTime() + this.source().delay() + this.source().duration() * this.source().iterations() + |
| this.source().endDelay(); |
| } |
| |
| // For scroll driven animations, it returns the duration in pixels (i.e. after how many pixels of scroll the animation is going to end) |
| // For time animations, it returns milliseconds. |
| finiteDuration(): number { |
| const iterations = Math.min(this.source().iterations(), 3); |
| if (this.viewOrScrollTimeline()) { |
| return this.iterationDuration() * iterations; |
| } |
| |
| return this.source().delay() + this.source().duration() * iterations; |
| } |
| |
| // For scroll driven animations, it returns the duration in pixels (i.e. after how many pixels of scroll the animation is going to end) |
| // For time animations, it returns milliseconds. |
| currentTime(): number { |
| const viewOrScrollTimeline = this.viewOrScrollTimeline(); |
| if (viewOrScrollTimeline) { |
| return this.percentageToPixels(this.#payload.currentTime, viewOrScrollTimeline); |
| } |
| |
| return this.#payload.currentTime; |
| } |
| |
| source(): AnimationEffect { |
| return this.#source; |
| } |
| |
| type(): Protocol.Animation.AnimationType { |
| return this.#payload.type; |
| } |
| |
| overlaps(animation: AnimationImpl): boolean { |
| // Infinite animations |
| if (!this.source().iterations() || !animation.source().iterations()) { |
| return true; |
| } |
| |
| const firstAnimation = this.startTime() < animation.startTime() ? this : animation; |
| const secondAnimation = firstAnimation === this ? animation : this; |
| return firstAnimation.endTime() >= secondAnimation.startTime(); |
| } |
| |
| // Utility method for returning `delay` for time based animations |
| // and `startTime` in pixels for scroll driven animations. It is used to |
| // find the exact starting time of the first keyframe for both cases. |
| delayOrStartTime(): number { |
| if (this.viewOrScrollTimeline()) { |
| return this.startTime(); |
| } |
| |
| return this.source().delay(); |
| } |
| |
| setTiming(duration: number, delay: number): void { |
| void this.#source.node().then(node => { |
| if (!node) { |
| throw new Error('Unable to find node'); |
| } |
| this.updateNodeStyle(duration, delay, node); |
| }); |
| this.#source.durationInternal = duration; |
| this.#source.delayInternal = delay; |
| void this.#animationModel.agent.invoke_setTiming({animationId: this.id(), duration, delay}); |
| } |
| |
| private updateNodeStyle(duration: number, delay: number, node: DOMNode): void { |
| let animationPrefix; |
| if (this.type() === Protocol.Animation.AnimationType.CSSTransition) { |
| animationPrefix = 'transition-'; |
| } else if (this.type() === Protocol.Animation.AnimationType.CSSAnimation) { |
| animationPrefix = 'animation-'; |
| } else { |
| return; |
| } |
| |
| if (!node.id) { |
| throw new Error('Node has no id'); |
| } |
| |
| const cssModel = node.domModel().cssModel(); |
| cssModel.setEffectivePropertyValueForNode(node.id, animationPrefix + 'duration', duration + 'ms'); |
| cssModel.setEffectivePropertyValueForNode(node.id, animationPrefix + 'delay', delay + 'ms'); |
| } |
| |
| async remoteObjectPromise(): Promise<RemoteObject|null> { |
| const payload = await this.#animationModel.agent.invoke_resolveAnimation({animationId: this.id()}); |
| if (!payload) { |
| return null; |
| } |
| |
| return this.#animationModel.runtimeModel.createRemoteObject(payload.remoteObject); |
| } |
| |
| cssId(): string { |
| return this.#payload.cssId || ''; |
| } |
| } |
| |
| export class AnimationEffect { |
| #animationModel: AnimationModel; |
| #payload!: Protocol.Animation |
| .AnimationEffect; // Assertion is safe because `setPayload` call in `constructor` sets the value. |
| delayInternal!: number; // Assertion is safe because `setPayload` call in `constructor` sets the value. |
| durationInternal!: number; // Assertion is safe because `setPayload` call in `constructor` sets the value. |
| #keyframesRule: KeyframesRule|undefined; |
| #deferredNode?: DeferredDOMNode; |
| constructor(animationModel: AnimationModel, payload: Protocol.Animation.AnimationEffect) { |
| this.#animationModel = animationModel; |
| this.setPayload(payload); |
| } |
| |
| setPayload(payload: Protocol.Animation.AnimationEffect): void { |
| this.#payload = payload; |
| if (!this.#keyframesRule && payload.keyframesRule) { |
| this.#keyframesRule = new KeyframesRule(payload.keyframesRule); |
| } else if (this.#keyframesRule && payload.keyframesRule) { |
| this.#keyframesRule.setPayload(payload.keyframesRule); |
| } |
| |
| this.delayInternal = payload.delay; |
| this.durationInternal = payload.duration; |
| } |
| |
| delay(): number { |
| return this.delayInternal; |
| } |
| |
| endDelay(): number { |
| return this.#payload.endDelay; |
| } |
| |
| iterations(): number { |
| // Animations with zero duration, zero delays and infinite iterations can't be shown. |
| if (!this.delay() && !this.endDelay() && !this.duration()) { |
| return 0; |
| } |
| return this.#payload.iterations || Infinity; |
| } |
| |
| duration(): number { |
| return this.durationInternal; |
| } |
| |
| direction(): string { |
| return this.#payload.direction; |
| } |
| |
| fill(): string { |
| return this.#payload.fill; |
| } |
| |
| node(): Promise<DOMNode|null> { |
| if (!this.#deferredNode) { |
| this.#deferredNode = new DeferredDOMNode(this.#animationModel.target(), this.backendNodeId()); |
| } |
| return this.#deferredNode.resolvePromise(); |
| } |
| |
| deferredNode(): DeferredDOMNode { |
| return new DeferredDOMNode(this.#animationModel.target(), this.backendNodeId()); |
| } |
| |
| backendNodeId(): Protocol.DOM.BackendNodeId { |
| return this.#payload.backendNodeId as Protocol.DOM.BackendNodeId; |
| } |
| |
| keyframesRule(): KeyframesRule|null { |
| return this.#keyframesRule || null; |
| } |
| |
| easing(): string { |
| return this.#payload.easing; |
| } |
| } |
| |
| export class KeyframesRule { |
| #payload!: Protocol.Animation |
| .KeyframesRule; // Assertion is safe because `setPayload` call in `constructor` sets the value.; |
| #keyframes!: KeyframeStyle[]; // Assertion is safe because `setPayload` call in `constructor` sets the value.; |
| constructor(payload: Protocol.Animation.KeyframesRule) { |
| this.setPayload(payload); |
| } |
| |
| setPayload(payload: Protocol.Animation.KeyframesRule): void { |
| this.#payload = payload; |
| if (!this.#keyframes) { |
| this.#keyframes = this.#payload.keyframes.map(keyframeStyle => new KeyframeStyle(keyframeStyle)); |
| } else { |
| this.#payload.keyframes.forEach((keyframeStyle, index) => { |
| this.#keyframes[index]?.setPayload(keyframeStyle); |
| }); |
| } |
| } |
| |
| name(): string|undefined { |
| return this.#payload.name; |
| } |
| |
| keyframes(): KeyframeStyle[] { |
| return this.#keyframes; |
| } |
| } |
| |
| export class KeyframeStyle { |
| #payload!: |
| Protocol.Animation.KeyframeStyle; // Assertion is safe because `setPayload` call in `constructor` sets the value. |
| #offset!: string; // Assertion is safe because `setPayload` call in `constructor` sets the value. |
| constructor(payload: Protocol.Animation.KeyframeStyle) { |
| this.setPayload(payload); |
| } |
| |
| setPayload(payload: Protocol.Animation.KeyframeStyle): void { |
| this.#payload = payload; |
| this.#offset = payload.offset; |
| } |
| |
| offset(): string { |
| return this.#offset; |
| } |
| |
| setOffset(offset: number): void { |
| this.#offset = offset * 100 + '%'; |
| } |
| |
| offsetAsNumber(): number { |
| return parseFloat(this.#offset) / 100; |
| } |
| |
| easing(): string { |
| return this.#payload.easing; |
| } |
| } |
| |
| export class AnimationGroup { |
| readonly #animationModel: AnimationModel; |
| readonly #id: string; |
| #scrollNode: AnimationDOMNode|undefined; |
| #animations: AnimationImpl[]; |
| #paused = false; |
| constructor(animationModel: AnimationModel, id: string, animations: AnimationImpl[]) { |
| this.#animationModel = animationModel; |
| this.#id = id; |
| this.#animations = animations; |
| } |
| |
| isScrollDriven(): boolean { |
| return Boolean(this.#animations[0]?.viewOrScrollTimeline()); |
| } |
| |
| id(): string { |
| return this.#id; |
| } |
| |
| animations(): AnimationImpl[] { |
| return this.#animations; |
| } |
| |
| release(): void { |
| this.#animationModel.animationGroups.delete(this.id()); |
| this.#animationModel.releaseAnimations(this.animationIds()); |
| } |
| |
| private animationIds(): string[] { |
| function extractId(animation: AnimationImpl): string { |
| return animation.id(); |
| } |
| |
| return this.#animations.map(extractId); |
| } |
| |
| startTime(): number { |
| return this.#animations[0].startTime(); |
| } |
| |
| // For scroll driven animations, it returns the duration in pixels (i.e. after how many pixels of scroll the animation is going to end) |
| // For time animations, it returns milliseconds. |
| groupDuration(): number { |
| let duration = 0; |
| for (const anim of this.#animations) { |
| duration = Math.max(duration, anim.delayOrStartTime() + anim.iterationDuration()); |
| } |
| return duration; |
| } |
| |
| // For scroll driven animations, it returns the duration in pixels (i.e. after how many pixels of scroll the animation is going to end) |
| // For time animations, it returns milliseconds. |
| finiteDuration(): number { |
| let maxDuration = 0; |
| for (let i = 0; i < this.#animations.length; ++i) { |
| maxDuration = Math.max(maxDuration, this.#animations[i].finiteDuration()); |
| } |
| return maxDuration; |
| } |
| |
| scrollOrientation(): Protocol.DOM.ScrollOrientation|null { |
| const timeline = this.#animations[0]?.viewOrScrollTimeline(); |
| if (!timeline) { |
| return null; |
| } |
| |
| return timeline.axis; |
| } |
| |
| async scrollNode(): Promise<AnimationDOMNode|null> { |
| if (this.#scrollNode) { |
| return this.#scrollNode; |
| } |
| |
| if (!this.isScrollDriven()) { |
| return null; |
| } |
| |
| const sourceNodeId = this.#animations[0]?.viewOrScrollTimeline()?.sourceNodeId; |
| if (!sourceNodeId) { |
| return null; |
| } |
| |
| const deferredScrollNode = new DeferredDOMNode(this.#animationModel.target(), sourceNodeId); |
| const scrollNode = await deferredScrollNode.resolvePromise(); |
| if (!scrollNode) { |
| return null; |
| } |
| |
| this.#scrollNode = new AnimationDOMNode(scrollNode); |
| return this.#scrollNode; |
| } |
| |
| seekTo(currentTime: number): void { |
| void this.#animationModel.agent.invoke_seekAnimations({animations: this.animationIds(), currentTime}); |
| } |
| |
| paused(): boolean { |
| return this.#paused; |
| } |
| |
| togglePause(paused: boolean): void { |
| if (paused === this.#paused) { |
| return; |
| } |
| this.#paused = paused; |
| void this.#animationModel.agent.invoke_setPaused({animations: this.animationIds(), paused}); |
| } |
| |
| currentTimePromise(): Promise<number> { |
| let longestAnim: AnimationImpl|null = null; |
| for (const anim of this.#animations) { |
| if (!longestAnim || anim.endTime() > longestAnim.endTime()) { |
| longestAnim = anim; |
| } |
| } |
| if (!longestAnim) { |
| throw new Error('No longest animation found'); |
| } |
| |
| return this.#animationModel.agent.invoke_getCurrentTime({id: longestAnim.id()}) |
| .then(({currentTime}) => currentTime || 0); |
| } |
| |
| matches(group: AnimationGroup): boolean { |
| function extractId(anim: AnimationImpl): string { |
| const timelineId = (anim.viewOrScrollTimeline()?.sourceNodeId ?? '') + (anim.viewOrScrollTimeline()?.axis ?? ''); |
| const regularId = |
| anim.type() === Protocol.Animation.AnimationType.WebAnimation ? anim.type() + anim.id() : anim.cssId(); |
| |
| return regularId + timelineId; |
| } |
| |
| if (this.#animations.length !== group.#animations.length) { |
| return false; |
| } |
| const left = this.#animations.map(extractId).sort(); |
| const right = group.#animations.map(extractId).sort(); |
| for (let i = 0; i < left.length; i++) { |
| if (left[i] !== right[i]) { |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| shouldInclude(group: AnimationGroup): boolean { |
| // We want to include the animations coming from the incoming group |
| // inside this group if they were to be grouped if the events came at the same time. |
| const [firstIncomingAnimation] = group.#animations; |
| const [firstAnimation] = this.#animations; |
| return shouldGroupAnimations(firstAnimation, firstIncomingAnimation); |
| } |
| |
| appendAnimations(animations: AnimationImpl[]): void { |
| this.#animations.push(...animations); |
| } |
| |
| rebaseTo(group: AnimationGroup): void { |
| this.#animationModel.releaseAnimations(this.animationIds()); |
| this.#animations = group.#animations; |
| this.#scrollNode = undefined; |
| } |
| } |
| |
| export class AnimationDispatcher implements ProtocolProxyApi.AnimationDispatcher { |
| readonly #animationModel: AnimationModel; |
| constructor(animationModel: AnimationModel) { |
| this.#animationModel = animationModel; |
| } |
| |
| animationCreated(_event: Protocol.Animation.AnimationCreatedEvent): void { |
| // Previously this event was used to batch the animations into groups |
| // and we were waiting for animationStarted events to be sent for |
| // all the created animations and until then we weren't creating any |
| // groups. This was allowing us to not miss any animations that were |
| // going to be in the same group. However, now we're not using this event |
| // to do batching and instead: |
| // * We debounce the flush calls so that if the animationStarted events |
| // for the same animation group come in different times; we create one |
| // group for them. |
| // * Even though an animation group is created and rendered for some animations |
| // that have the same startTime (or same timeline & scroll axis for SDAs), now |
| // whenever an `animationStarted` event comes we check whether there is a group |
| // we can add the related animation. If so, we add it and emit `animationGroupUpdated` |
| // event. So that, all the animations that were supposed to be in the same group |
| // will be in the same group. |
| } |
| |
| animationCanceled({id}: Protocol.Animation.AnimationCanceledEvent): void { |
| this.#animationModel.animationCanceled(id); |
| } |
| |
| animationStarted({animation}: Protocol.Animation.AnimationStartedEvent): void { |
| void this.#animationModel.animationStarted(animation); |
| } |
| |
| animationUpdated({animation}: Protocol.Animation.AnimationUpdatedEvent): void { |
| void this.#animationModel.animationUpdated(animation); |
| } |
| } |
| |
| SDKModel.register(AnimationModel, {capabilities: Capability.DOM, autostart: true}); |
| export interface Request { |
| endTime: number; |
| } |