UNPKG

chrome-devtools-frontend

Version:
1,042 lines (866 loc) • 36.4 kB
// 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 && 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}); } async releaseAllAnimations(): Promise<void> { const animationIds = [...this.animationGroups.values()].flatMap( animationGroup => animationGroup.animations().map(animation => animation.id())); await this.agent.invoke_releaseAnimations({animations: animationIds}); } 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: boolean; constructor(animationModel: AnimationModel, id: string, animations: AnimationImpl[]) { this.#animationModel = animationModel; this.#id = id; this.#animations = animations; this.#paused = false; } 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; }