mirror of
https://github.com/react/react-native-devtools-frontend.git
synced 2026-10-11 14:14:36 +08:00
The eslint rule 'comma-dangle' isn't enough to enforce dangling commas in TypeScript, we must use '@typescript-eslint/comma-dangle' instead. Bug: chromium:1174055 Change-Id: I589e1016ab578c965b54f17da3eab7ea542a2f7a Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/2672028 Reviewed-by: Tim van der Lippe <tvanderlippe@chromium.org> Reviewed-by: Alex Rudenko <alexrudenko@chromium.org> Commit-Queue: Sigurd Schneider <sigurds@chromium.org>
626 lines
18 KiB
TypeScript
626 lines
18 KiB
TypeScript
// Copyright (c) 2014 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 SDK from '../sdk/sdk.js';
|
|
|
|
export class AnimationModel extends SDK.SDKModel.SDKModel {
|
|
_runtimeModel: SDK.RuntimeModel.RuntimeModel;
|
|
_agent: ProtocolProxyApi.AnimationApi;
|
|
_animationsById: Map<string, AnimationImpl>;
|
|
_animationGroups: Map<string, AnimationGroup>;
|
|
_pendingAnimations: Set<string>;
|
|
_playbackRate: number;
|
|
_screenshotCapture?: ScreenshotCapture;
|
|
_enabled?: boolean;
|
|
|
|
constructor(target: SDK.SDKModel.Target) {
|
|
super(target);
|
|
this._runtimeModel = (target.model(SDK.RuntimeModel.RuntimeModel) as SDK.RuntimeModel.RuntimeModel);
|
|
this._agent = target.animationAgent();
|
|
target.registerAnimationDispatcher(new AnimationDispatcher(this));
|
|
this._animationsById = new Map();
|
|
this._animationGroups = new Map();
|
|
this._pendingAnimations = new Set();
|
|
this._playbackRate = 1;
|
|
const resourceTreeModel =
|
|
(target.model(SDK.ResourceTreeModel.ResourceTreeModel) as SDK.ResourceTreeModel.ResourceTreeModel);
|
|
resourceTreeModel.addEventListener(SDK.ResourceTreeModel.Events.MainFrameNavigated, this._reset, this);
|
|
const screenCaptureModel = target.model(SDK.ScreenCaptureModel.ScreenCaptureModel);
|
|
if (screenCaptureModel) {
|
|
this._screenshotCapture = new ScreenshotCapture(this, screenCaptureModel);
|
|
}
|
|
}
|
|
|
|
_reset(): void {
|
|
this._animationsById.clear();
|
|
this._animationGroups.clear();
|
|
this._pendingAnimations.clear();
|
|
this.dispatchEventToListeners(Events.ModelReset);
|
|
}
|
|
|
|
animationCreated(id: string): void {
|
|
this._pendingAnimations.add(id);
|
|
}
|
|
|
|
_animationCanceled(id: string): void {
|
|
this._pendingAnimations.delete(id);
|
|
this._flushPendingAnimationsIfNeeded();
|
|
}
|
|
|
|
animationStarted(payload: Protocol.Animation.Animation): void {
|
|
// We are not interested in animations without effect or target.
|
|
if (!payload.source || !payload.source.backendNodeId) {
|
|
return;
|
|
}
|
|
|
|
const animation = AnimationImpl.parsePayload(this, payload);
|
|
if (!animation) {
|
|
return;
|
|
}
|
|
|
|
// 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._flushPendingAnimationsIfNeeded();
|
|
}
|
|
|
|
_flushPendingAnimationsIfNeeded(): void {
|
|
for (const id of this._pendingAnimations) {
|
|
if (!this._animationsById.get(id)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
while (this._pendingAnimations.size) {
|
|
this._matchExistingGroups(this._createGroupFromPendingAnimations());
|
|
}
|
|
}
|
|
|
|
_matchExistingGroups(incomingGroup: AnimationGroup): boolean {
|
|
let matchedGroup: AnimationGroup|null = null;
|
|
for (const group of this._animationGroups.values()) {
|
|
if (group._matches(incomingGroup)) {
|
|
matchedGroup = group;
|
|
group._update(incomingGroup);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!matchedGroup) {
|
|
this._animationGroups.set(incomingGroup.id(), incomingGroup);
|
|
if (this._screenshotCapture) {
|
|
this._screenshotCapture.captureScreenshots(incomingGroup.finiteDuration(), incomingGroup._screenshots);
|
|
}
|
|
}
|
|
this.dispatchEventToListeners(Events.AnimationGroupStarted, matchedGroup || incomingGroup);
|
|
return Boolean(matchedGroup);
|
|
}
|
|
|
|
_createGroupFromPendingAnimations(): AnimationGroup {
|
|
console.assert(this._pendingAnimations.size > 0);
|
|
const firstAnimationId = this._pendingAnimations.values().next().value;
|
|
this._pendingAnimations.delete(firstAnimationId);
|
|
|
|
const firstAnimation = this._animationsById.get(firstAnimationId);
|
|
if (!firstAnimation) {
|
|
throw new Error('Unable to locate first animation');
|
|
}
|
|
|
|
const groupedAnimations = [firstAnimation];
|
|
const groupStartTime = firstAnimation.startTime();
|
|
const remainingAnimations = new Set<string>();
|
|
for (const id of this._pendingAnimations) {
|
|
const anim = (this._animationsById.get(id) as AnimationImpl);
|
|
if (anim.startTime() === groupStartTime) {
|
|
groupedAnimations.push(anim);
|
|
} else {
|
|
remainingAnimations.add(id);
|
|
}
|
|
}
|
|
this._pendingAnimations = remainingAnimations;
|
|
return new AnimationGroup(this, firstAnimationId, groupedAnimations);
|
|
}
|
|
|
|
setPlaybackRate(playbackRate: number): void {
|
|
this._playbackRate = playbackRate;
|
|
this._agent.invoke_setPlaybackRate({playbackRate});
|
|
}
|
|
|
|
_releaseAnimations(animations: string[]): void {
|
|
this._agent.invoke_releaseAnimations({animations});
|
|
}
|
|
|
|
async suspendModel(): Promise<void> {
|
|
this._reset();
|
|
await this._agent.invoke_disable();
|
|
}
|
|
|
|
async resumeModel(): Promise<void> {
|
|
if (!this._enabled) {
|
|
return;
|
|
}
|
|
await this._agent.invoke_enable();
|
|
}
|
|
|
|
async ensureEnabled(): Promise<void> {
|
|
if (this._enabled) {
|
|
return;
|
|
}
|
|
await this._agent.invoke_enable();
|
|
this._enabled = true;
|
|
}
|
|
}
|
|
|
|
// TODO(crbug.com/1167717): Make this a const enum again
|
|
// eslint-disable-next-line rulesdir/const_enum
|
|
export enum Events {
|
|
AnimationGroupStarted = 'AnimationGroupStarted',
|
|
ModelReset = 'ModelReset',
|
|
}
|
|
|
|
export class AnimationImpl {
|
|
_animationModel: AnimationModel;
|
|
_payload: Protocol.Animation.Animation;
|
|
_source: AnimationEffect;
|
|
_playState?: string;
|
|
constructor(animationModel: AnimationModel, payload: Protocol.Animation.Animation) {
|
|
this._animationModel = animationModel;
|
|
this._payload = payload;
|
|
this._source = new AnimationEffect(animationModel, (this._payload.source as Protocol.Animation.AnimationEffect));
|
|
}
|
|
|
|
static parsePayload(animationModel: AnimationModel, payload: Protocol.Animation.Animation): AnimationImpl {
|
|
return new AnimationImpl(animationModel, payload);
|
|
}
|
|
|
|
payload(): Protocol.Animation.Animation {
|
|
return this._payload;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
setPlayState(playState: string): void {
|
|
this._playState = playState;
|
|
}
|
|
|
|
playbackRate(): number {
|
|
return this._payload.playbackRate;
|
|
}
|
|
|
|
startTime(): number {
|
|
return this._payload.startTime;
|
|
}
|
|
|
|
endTime(): number {
|
|
if (!this.source().iterations) {
|
|
return Infinity;
|
|
}
|
|
return this.startTime() + this.source().delay() + this.source().duration() * this.source().iterations() +
|
|
this.source().endDelay();
|
|
}
|
|
|
|
_finiteDuration(): number {
|
|
const iterations = Math.min(this.source().iterations(), 3);
|
|
return this.source().delay() + this.source().duration() * iterations;
|
|
}
|
|
|
|
currentTime(): number {
|
|
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();
|
|
}
|
|
|
|
setTiming(duration: number, delay: number): void {
|
|
this._source.node().then(node => {
|
|
if (!node) {
|
|
throw new Error('Unable to find node');
|
|
}
|
|
this._updateNodeStyle(duration, delay, node);
|
|
});
|
|
this._source._duration = duration;
|
|
this._source._delay = delay;
|
|
this._animationModel._agent.invoke_setTiming({animationId: this.id(), duration, delay});
|
|
}
|
|
|
|
_updateNodeStyle(duration: number, delay: number, node: SDK.DOMModel.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<SDK.RemoteObject.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;
|
|
_keyframesRule: KeyframesRule|undefined;
|
|
_delay: number;
|
|
_duration: number;
|
|
_deferredNode?: SDK.DOMModel.DeferredDOMNode;
|
|
constructor(animationModel: AnimationModel, payload: Protocol.Animation.AnimationEffect) {
|
|
this._animationModel = animationModel;
|
|
this._payload = payload;
|
|
if (payload.keyframesRule) {
|
|
this._keyframesRule = new KeyframesRule(payload.keyframesRule);
|
|
}
|
|
this._delay = this._payload.delay;
|
|
this._duration = this._payload.duration;
|
|
}
|
|
|
|
delay(): number {
|
|
return this._delay;
|
|
}
|
|
|
|
endDelay(): number {
|
|
return this._payload.endDelay;
|
|
}
|
|
|
|
iterationStart(): number {
|
|
return this._payload.iterationStart;
|
|
}
|
|
|
|
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._duration;
|
|
}
|
|
|
|
direction(): string {
|
|
return this._payload.direction;
|
|
}
|
|
|
|
fill(): string {
|
|
return this._payload.fill;
|
|
}
|
|
|
|
node(): Promise<SDK.DOMModel.DOMNode|null> {
|
|
if (!this._deferredNode) {
|
|
this._deferredNode = new SDK.DOMModel.DeferredDOMNode(this._animationModel.target(), this.backendNodeId());
|
|
}
|
|
return this._deferredNode.resolvePromise();
|
|
}
|
|
|
|
deferredNode(): SDK.DOMModel.DeferredDOMNode {
|
|
return new SDK.DOMModel.DeferredDOMNode(this._animationModel.target(), this.backendNodeId());
|
|
}
|
|
|
|
backendNodeId(): number {
|
|
return this._payload.backendNodeId as number;
|
|
}
|
|
|
|
keyframesRule(): KeyframesRule|null {
|
|
return this._keyframesRule || null;
|
|
}
|
|
|
|
easing(): string {
|
|
return this._payload.easing;
|
|
}
|
|
}
|
|
|
|
export class KeyframesRule {
|
|
_payload: Protocol.Animation.KeyframesRule;
|
|
_keyframes: KeyframeStyle[];
|
|
constructor(payload: Protocol.Animation.KeyframesRule) {
|
|
this._payload = payload;
|
|
this._keyframes = this._payload.keyframes.map(function(keyframeStyle) {
|
|
return new KeyframeStyle(keyframeStyle);
|
|
});
|
|
}
|
|
|
|
_setKeyframesPayload(payload: Protocol.Animation.KeyframeStyle[]): void {
|
|
this._keyframes = payload.map(function(keyframeStyle) {
|
|
return new KeyframeStyle(keyframeStyle);
|
|
});
|
|
}
|
|
|
|
name(): string|undefined {
|
|
return this._payload.name;
|
|
}
|
|
|
|
keyframes(): KeyframeStyle[] {
|
|
return this._keyframes;
|
|
}
|
|
}
|
|
|
|
export class KeyframeStyle {
|
|
_payload: Protocol.Animation.KeyframeStyle;
|
|
_offset: string;
|
|
constructor(payload: Protocol.Animation.KeyframeStyle) {
|
|
this._payload = payload;
|
|
this._offset = this._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 {
|
|
_animationModel: AnimationModel;
|
|
_id: string;
|
|
_animations: AnimationImpl[];
|
|
_paused: boolean;
|
|
_screenshots: string[];
|
|
_screenshotImages: HTMLImageElement[];
|
|
constructor(animationModel: AnimationModel, id: string, animations: AnimationImpl[]) {
|
|
this._animationModel = animationModel;
|
|
this._id = id;
|
|
this._animations = animations;
|
|
this._paused = false;
|
|
this._screenshots = [];
|
|
|
|
this._screenshotImages = [];
|
|
}
|
|
|
|
id(): string {
|
|
return this._id;
|
|
}
|
|
|
|
animations(): AnimationImpl[] {
|
|
return this._animations;
|
|
}
|
|
|
|
release(): void {
|
|
this._animationModel._animationGroups.delete(this.id());
|
|
this._animationModel._releaseAnimations(this._animationIds());
|
|
}
|
|
|
|
_animationIds(): string[] {
|
|
function extractId(animation: AnimationImpl): string {
|
|
return animation.id();
|
|
}
|
|
|
|
return this._animations.map(extractId);
|
|
}
|
|
|
|
startTime(): number {
|
|
return this._animations[0].startTime();
|
|
}
|
|
|
|
finiteDuration(): number {
|
|
let maxDuration = 0;
|
|
for (let i = 0; i < this._animations.length; ++i) {
|
|
maxDuration = Math.max(maxDuration, this._animations[i]._finiteDuration());
|
|
}
|
|
return maxDuration;
|
|
}
|
|
|
|
seekTo(currentTime: number): 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;
|
|
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 {
|
|
if (anim.type() === Protocol.Animation.AnimationType.WebAnimation) {
|
|
return anim.type() + anim.id();
|
|
}
|
|
return anim._cssId();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
_update(group: AnimationGroup): void {
|
|
this._animationModel._releaseAnimations(this._animationIds());
|
|
this._animations = group._animations;
|
|
}
|
|
|
|
screenshots(): HTMLImageElement[] {
|
|
for (let i = 0; i < this._screenshots.length; ++i) {
|
|
const image = new Image();
|
|
image.src = 'data:image/jpeg;base64,' + this._screenshots[i];
|
|
this._screenshotImages.push(image);
|
|
}
|
|
this._screenshots = [];
|
|
return this._screenshotImages;
|
|
}
|
|
}
|
|
|
|
export class AnimationDispatcher implements ProtocolProxyApi.AnimationDispatcher {
|
|
_animationModel: AnimationModel;
|
|
constructor(animationModel: AnimationModel) {
|
|
this._animationModel = animationModel;
|
|
}
|
|
|
|
animationCreated({id}: Protocol.Animation.AnimationCreatedEvent): void {
|
|
this._animationModel.animationCreated(id);
|
|
}
|
|
|
|
animationCanceled({id}: Protocol.Animation.AnimationCanceledEvent): void {
|
|
this._animationModel._animationCanceled(id);
|
|
}
|
|
|
|
animationStarted({animation}: Protocol.Animation.AnimationStartedEvent): void {
|
|
this._animationModel.animationStarted(animation);
|
|
}
|
|
}
|
|
|
|
export class ScreenshotCapture {
|
|
_requests: Request[];
|
|
_screenCaptureModel: SDK.ScreenCaptureModel.ScreenCaptureModel;
|
|
_animationModel: AnimationModel;
|
|
_stopTimer?: number;
|
|
_endTime?: number;
|
|
_capturing?: boolean;
|
|
constructor(animationModel: AnimationModel, screenCaptureModel: SDK.ScreenCaptureModel.ScreenCaptureModel) {
|
|
this._requests = [];
|
|
this._screenCaptureModel = screenCaptureModel;
|
|
this._animationModel = animationModel;
|
|
this._animationModel.addEventListener(Events.ModelReset, this._stopScreencast, this);
|
|
}
|
|
|
|
captureScreenshots(duration: number, screenshots: string[]): void {
|
|
const screencastDuration = Math.min(duration / this._animationModel._playbackRate, 3000);
|
|
const endTime = screencastDuration + window.performance.now();
|
|
this._requests.push({endTime: endTime, screenshots: screenshots});
|
|
|
|
if (!this._endTime || endTime > this._endTime) {
|
|
clearTimeout(this._stopTimer);
|
|
this._stopTimer = window.setTimeout(this._stopScreencast.bind(this), screencastDuration);
|
|
this._endTime = endTime;
|
|
}
|
|
|
|
if (this._capturing) {
|
|
return;
|
|
}
|
|
this._capturing = true;
|
|
this._screenCaptureModel.startScreencast(
|
|
Protocol.Page.StartScreencastRequestFormat.Jpeg, 80, undefined, 300, 2, this._screencastFrame.bind(this),
|
|
_visible => {});
|
|
}
|
|
|
|
_screencastFrame(base64Data: string, _metadata: Protocol.Page.ScreencastFrameMetadata): void {
|
|
function isAnimating(request: Request): boolean {
|
|
return request.endTime >= now;
|
|
}
|
|
|
|
if (!this._capturing) {
|
|
return;
|
|
}
|
|
|
|
const now = window.performance.now();
|
|
this._requests = this._requests.filter(isAnimating);
|
|
for (const request of this._requests) {
|
|
request.screenshots.push(base64Data);
|
|
}
|
|
}
|
|
|
|
_stopScreencast(): void {
|
|
if (!this._capturing) {
|
|
return;
|
|
}
|
|
|
|
delete this._stopTimer;
|
|
delete this._endTime;
|
|
this._requests = [];
|
|
this._capturing = false;
|
|
this._screenCaptureModel.stopScreencast();
|
|
}
|
|
}
|
|
|
|
SDK.SDKModel.SDKModel.register(AnimationModel, SDK.SDKModel.Capability.DOM, false);
|
|
export interface Request {
|
|
endTime: number;
|
|
screenshots: string[];
|
|
}
|