forked from mirrors/gecko-dev
MozReview-Commit-ID: F6xUXCgdRE4 --HG-- extra : rebase_source : 65de1b0aba412d9044b5196115f74276caa058f2
806 lines
26 KiB
JavaScript
806 lines
26 KiB
JavaScript
/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
|
|
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
"use strict";
|
|
|
|
const EventEmitter = require("devtools/shared/event-emitter");
|
|
const {
|
|
createNode,
|
|
findOptimalTimeInterval,
|
|
getFormattedAnimationTitle,
|
|
TimeScale,
|
|
getCssPropertyName
|
|
} = require("devtools/client/inspector/animation-old/utils");
|
|
const { AnimationDetails } = require("devtools/client/inspector/animation-old/components/animation-details");
|
|
const { AnimationTargetNode } = require("devtools/client/inspector/animation-old/components/animation-target-node");
|
|
const { AnimationTimeBlock } = require("devtools/client/inspector/animation-old/components/animation-time-block");
|
|
|
|
const { LocalizationHelper } = require("devtools/shared/l10n");
|
|
const L10N =
|
|
new LocalizationHelper("devtools/client/locales/animationinspector.properties");
|
|
|
|
// The minimum spacing between 2 time graduation headers in the timeline (px).
|
|
const TIME_GRADUATION_MIN_SPACING = 40;
|
|
// When the container window is resized, the timeline background gets refreshed,
|
|
// but only after a timer, and the timer is reset if the window is continuously
|
|
// resized.
|
|
const TIMELINE_BACKGROUND_RESIZE_DEBOUNCE_TIMER = 50;
|
|
|
|
/**
|
|
* UI component responsible for displaying a timeline for animations.
|
|
* The timeline is essentially a graph with time along the x axis and animations
|
|
* along the y axis.
|
|
* The time is represented with a graduation header at the top and a current
|
|
* time play head.
|
|
* Animations are organized by lines, with a left margin containing the preview
|
|
* of the target DOM element the animation applies to.
|
|
* The current time play head can be moved by clicking/dragging in the header.
|
|
* when this happens, the component emits "current-data-changed" events with the
|
|
* new time and state of the timeline.
|
|
*
|
|
* @param {InspectorPanel} inspector.
|
|
* @param {Object} serverTraits The list of server-side capabilities.
|
|
*/
|
|
function AnimationsTimeline(inspector, serverTraits) {
|
|
this.animations = [];
|
|
this.componentsMap = {};
|
|
this.inspector = inspector;
|
|
this.serverTraits = serverTraits;
|
|
|
|
this.onAnimationStateChanged = this.onAnimationStateChanged.bind(this);
|
|
this.onScrubberMouseDown = this.onScrubberMouseDown.bind(this);
|
|
this.onScrubberMouseUp = this.onScrubberMouseUp.bind(this);
|
|
this.onScrubberMouseOut = this.onScrubberMouseOut.bind(this);
|
|
this.onScrubberMouseMove = this.onScrubberMouseMove.bind(this);
|
|
this.onAnimationSelected = this.onAnimationSelected.bind(this);
|
|
this.onWindowResize = this.onWindowResize.bind(this);
|
|
this.onTimelineDataChanged = this.onTimelineDataChanged.bind(this);
|
|
this.onDetailCloseButtonClick = this.onDetailCloseButtonClick.bind(this);
|
|
|
|
EventEmitter.decorate(this);
|
|
}
|
|
|
|
exports.AnimationsTimeline = AnimationsTimeline;
|
|
|
|
AnimationsTimeline.prototype = {
|
|
init: function(containerEl) {
|
|
this.win = containerEl.ownerDocument.defaultView;
|
|
this.rootWrapperEl = containerEl;
|
|
|
|
this.setupSplitBox();
|
|
this.setupAnimationTimeline();
|
|
this.setupAnimationDetail();
|
|
|
|
this.win.addEventListener("resize",
|
|
this.onWindowResize);
|
|
},
|
|
|
|
setupSplitBox: function() {
|
|
const browserRequire = this.win.BrowserLoader({
|
|
window: this.win,
|
|
useOnlyShared: true
|
|
}).require;
|
|
|
|
const { createFactory } = browserRequire("devtools/client/shared/vendor/react");
|
|
const dom = require("devtools/client/shared/vendor/react-dom-factories");
|
|
const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom");
|
|
|
|
const SplitBox = createFactory(
|
|
browserRequire("devtools/client/shared/components/splitter/SplitBox"));
|
|
|
|
const splitter = SplitBox({
|
|
className: "animation-root",
|
|
splitterSize: 1,
|
|
initialHeight: "50%",
|
|
endPanelControl: true,
|
|
startPanel: dom.div({
|
|
className: "animation-timeline"
|
|
}),
|
|
endPanel: dom.div({
|
|
className: "animation-detail"
|
|
}),
|
|
vert: false
|
|
});
|
|
|
|
ReactDOM.render(splitter, this.rootWrapperEl);
|
|
|
|
this.animationRootEl = this.rootWrapperEl.querySelector(".animation-root");
|
|
},
|
|
|
|
setupAnimationTimeline: function() {
|
|
const animationTimelineEl = this.rootWrapperEl.querySelector(".animation-timeline");
|
|
|
|
const scrubberContainer = createNode({
|
|
parent: animationTimelineEl,
|
|
attributes: {"class": "scrubber-wrapper"}
|
|
});
|
|
|
|
this.scrubberEl = createNode({
|
|
parent: scrubberContainer,
|
|
attributes: {
|
|
"class": "scrubber"
|
|
}
|
|
});
|
|
|
|
this.scrubberHandleEl = createNode({
|
|
parent: this.scrubberEl,
|
|
attributes: {
|
|
"class": "scrubber-handle"
|
|
}
|
|
});
|
|
createNode({
|
|
parent: this.scrubberHandleEl,
|
|
attributes: {
|
|
"class": "scrubber-line"
|
|
}
|
|
});
|
|
this.scrubberHandleEl.addEventListener("mousedown",
|
|
this.onScrubberMouseDown);
|
|
|
|
this.headerWrapper = createNode({
|
|
parent: animationTimelineEl,
|
|
attributes: {
|
|
"class": "header-wrapper"
|
|
}
|
|
});
|
|
|
|
this.timeHeaderEl = createNode({
|
|
parent: this.headerWrapper,
|
|
attributes: {
|
|
"class": "time-header track-container"
|
|
}
|
|
});
|
|
|
|
this.timeHeaderEl.addEventListener("mousedown",
|
|
this.onScrubberMouseDown);
|
|
|
|
this.timeTickEl = createNode({
|
|
parent: animationTimelineEl,
|
|
attributes: {
|
|
"class": "time-body track-container"
|
|
}
|
|
});
|
|
|
|
this.animationsEl = createNode({
|
|
parent: animationTimelineEl,
|
|
nodeType: "ul",
|
|
attributes: {
|
|
"class": "animations devtools-monospace"
|
|
}
|
|
});
|
|
},
|
|
|
|
setupAnimationDetail: function() {
|
|
const animationDetailEl = this.rootWrapperEl.querySelector(".animation-detail");
|
|
|
|
const animationDetailHeaderEl = createNode({
|
|
parent: animationDetailEl,
|
|
attributes: {
|
|
"class": "animation-detail-header"
|
|
}
|
|
});
|
|
|
|
const headerTitleEl = createNode({
|
|
parent: animationDetailHeaderEl,
|
|
attributes: {
|
|
"class": "devtools-toolbar"
|
|
}
|
|
});
|
|
|
|
createNode({
|
|
parent: headerTitleEl,
|
|
textContent: L10N.getStr("detail.headerTitle")
|
|
});
|
|
|
|
this.animationAnimationNameEl = createNode({
|
|
parent: headerTitleEl
|
|
});
|
|
|
|
this.animationDetailCloseButton = createNode({
|
|
parent: headerTitleEl,
|
|
nodeType: "button",
|
|
attributes: {
|
|
"class": "devtools-button",
|
|
title: L10N.getStr("detail.header.closeLabel"),
|
|
}
|
|
});
|
|
this.animationDetailCloseButton.addEventListener("click",
|
|
this.onDetailCloseButtonClick);
|
|
|
|
const animationDetailBodyEl = createNode({
|
|
parent: animationDetailEl,
|
|
attributes: {
|
|
"class": "animation-detail-body"
|
|
}
|
|
});
|
|
|
|
this.animatedPropertiesEl = createNode({
|
|
parent: animationDetailBodyEl,
|
|
attributes: {
|
|
"class": "animated-properties"
|
|
}
|
|
});
|
|
|
|
this.details = new AnimationDetails(this.serverTraits);
|
|
this.details.init(this.animatedPropertiesEl);
|
|
},
|
|
|
|
destroy: function() {
|
|
this.stopAnimatingScrubber();
|
|
this.unrender();
|
|
this.details.destroy();
|
|
|
|
this.win.removeEventListener("resize",
|
|
this.onWindowResize);
|
|
this.timeHeaderEl.removeEventListener("mousedown",
|
|
this.onScrubberMouseDown);
|
|
this.scrubberHandleEl.removeEventListener("mousedown",
|
|
this.onScrubberMouseDown);
|
|
this.animationDetailCloseButton.removeEventListener("click",
|
|
this.onDetailCloseButtonClick);
|
|
|
|
this.rootWrapperEl.remove();
|
|
this.animations = [];
|
|
this.componentsMap = null;
|
|
this.rootWrapperEl = null;
|
|
this.timeHeaderEl = null;
|
|
this.animationsEl = null;
|
|
this.animatedPropertiesEl = null;
|
|
this.scrubberEl = null;
|
|
this.scrubberHandleEl = null;
|
|
this.win = null;
|
|
this.inspector = null;
|
|
this.serverTraits = null;
|
|
this.animationDetailEl = null;
|
|
this.animationAnimationNameEl = null;
|
|
this.animatedPropertiesEl = null;
|
|
this.animationDetailCloseButton = null;
|
|
this.animationRootEl = null;
|
|
this.selectedAnimation = null;
|
|
|
|
this.isDestroyed = true;
|
|
},
|
|
|
|
/**
|
|
* Destroy all sub-components that have been created and stored on this instance.
|
|
*/
|
|
destroyAllSubComponents: function() {
|
|
for (const actorID in this.componentsMap) {
|
|
this.destroySubComponents(actorID);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Destroy sub-components which related to given actor id.
|
|
* @param {String} actor id
|
|
*/
|
|
destroySubComponents: function(actorID) {
|
|
const components = this.componentsMap[actorID];
|
|
components.timeBlock.destroy();
|
|
components.targetNode.destroy();
|
|
components.animationEl.remove();
|
|
delete components.state;
|
|
delete components.tracks;
|
|
delete this.componentsMap[actorID];
|
|
},
|
|
|
|
unrender: function() {
|
|
for (const animation of this.animations) {
|
|
animation.off("changed", this.onAnimationStateChanged);
|
|
}
|
|
this.stopAnimatingScrubber();
|
|
TimeScale.reset();
|
|
this.destroyAllSubComponents();
|
|
this.animationsEl.innerHTML = "";
|
|
this.off("timeline-data-changed", this.onTimelineDataChanged);
|
|
this.details.unrender();
|
|
},
|
|
|
|
onWindowResize: function() {
|
|
// Don't do anything if the root element has a width of 0
|
|
if (this.rootWrapperEl.offsetWidth === 0) {
|
|
return;
|
|
}
|
|
|
|
if (this.windowResizeTimer) {
|
|
this.win.clearTimeout(this.windowResizeTimer);
|
|
}
|
|
|
|
this.windowResizeTimer = this.win.setTimeout(() => {
|
|
this.drawHeaderAndBackground();
|
|
}, TIMELINE_BACKGROUND_RESIZE_DEBOUNCE_TIMER);
|
|
},
|
|
|
|
async onAnimationSelected(animation) {
|
|
const index = this.animations.indexOf(animation);
|
|
if (index === -1) {
|
|
return;
|
|
}
|
|
|
|
// Unselect an animation which was selected.
|
|
const animationEls = this.rootWrapperEl.querySelectorAll(".animation");
|
|
for (let i = 0; i < animationEls.length; i++) {
|
|
const animationEl = animationEls[i];
|
|
if (!animationEl.classList.contains("selected")) {
|
|
continue;
|
|
}
|
|
if (i === index) {
|
|
if (this.animationRootEl.classList.contains("animation-detail-visible")) {
|
|
// Already the animation is selected.
|
|
this.emit("animation-already-selected", this.animations[i]);
|
|
return;
|
|
}
|
|
} else {
|
|
animationEl.classList.remove("selected");
|
|
this.emit("animation-unselected", this.animations[i]);
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Add class of animation type to animatedPropertiesEl to display the compositor sign.
|
|
if (!this.animatedPropertiesEl.classList.contains(animation.state.type)) {
|
|
this.animatedPropertiesEl.className =
|
|
`animated-properties ${ animation.state.type }`;
|
|
}
|
|
|
|
// Select and render.
|
|
const selectedAnimationEl = animationEls[index];
|
|
selectedAnimationEl.classList.add("selected");
|
|
this.animationRootEl.classList.add("animation-detail-visible");
|
|
// Don't render if the detail displays same animation already.
|
|
if (animation !== this.details.animation) {
|
|
this.selectedAnimation = animation;
|
|
await this.details.render(animation, this.componentsMap[animation.actorID].tracks);
|
|
this.animationAnimationNameEl.textContent = getFormattedAnimationTitle(animation);
|
|
}
|
|
this.onTimelineDataChanged({ time: this.currentTime || 0 });
|
|
this.emit("animation-selected", animation);
|
|
},
|
|
|
|
/**
|
|
* When move the scrubber to the corresponding position
|
|
*/
|
|
onScrubberMouseDown: function(e) {
|
|
this.moveScrubberTo(e.pageX);
|
|
this.win.addEventListener("mouseup", this.onScrubberMouseUp);
|
|
this.win.addEventListener("mouseout", this.onScrubberMouseOut);
|
|
this.win.addEventListener("mousemove", this.onScrubberMouseMove);
|
|
|
|
// Prevent text selection while dragging.
|
|
e.preventDefault();
|
|
},
|
|
|
|
onScrubberMouseUp: function() {
|
|
this.cancelTimeHeaderDragging();
|
|
},
|
|
|
|
onScrubberMouseOut: function(e) {
|
|
// Check that mouseout happened on the window itself, and if yes, cancel
|
|
// the dragging.
|
|
if (!this.win.document.contains(e.relatedTarget)) {
|
|
this.cancelTimeHeaderDragging();
|
|
}
|
|
},
|
|
|
|
cancelTimeHeaderDragging: function() {
|
|
this.win.removeEventListener("mouseup", this.onScrubberMouseUp);
|
|
this.win.removeEventListener("mouseout", this.onScrubberMouseOut);
|
|
this.win.removeEventListener("mousemove", this.onScrubberMouseMove);
|
|
},
|
|
|
|
onScrubberMouseMove: function(e) {
|
|
this.moveScrubberTo(e.pageX);
|
|
},
|
|
|
|
moveScrubberTo: function(pageX, noOffset) {
|
|
this.stopAnimatingScrubber();
|
|
|
|
// The offset needs to be in % and relative to the timeline's area (so we
|
|
// subtract the scrubber's left offset, which is equal to the sidebar's
|
|
// width).
|
|
let offset = pageX;
|
|
if (!noOffset) {
|
|
offset -= this.timeHeaderEl.offsetLeft;
|
|
}
|
|
offset = offset * 100 / this.timeHeaderEl.offsetWidth;
|
|
if (offset < 0) {
|
|
offset = 0;
|
|
}
|
|
|
|
this.scrubberEl.style.left = offset + "%";
|
|
|
|
const time = TimeScale.distanceToRelativeTime(offset);
|
|
|
|
this.emit("timeline-data-changed", {
|
|
isPaused: true,
|
|
isMoving: false,
|
|
isUserDrag: true,
|
|
time: time
|
|
});
|
|
},
|
|
|
|
getCompositorStatusClassName: function(state) {
|
|
let className = state.isRunningOnCompositor
|
|
? " fast-track"
|
|
: "";
|
|
|
|
if (state.isRunningOnCompositor && state.propertyState) {
|
|
className +=
|
|
state.propertyState.some(propState => !propState.runningOnCompositor)
|
|
? " some-properties"
|
|
: " all-properties";
|
|
}
|
|
|
|
return className;
|
|
},
|
|
|
|
async render(animations, documentCurrentTime) {
|
|
this.animations = animations;
|
|
|
|
// Destroy components which are no longer existed in given animations.
|
|
for (const animation of this.animations) {
|
|
if (this.componentsMap[animation.actorID]) {
|
|
this.componentsMap[animation.actorID].needToLeave = true;
|
|
}
|
|
}
|
|
for (const actorID in this.componentsMap) {
|
|
const components = this.componentsMap[actorID];
|
|
if (components.needToLeave) {
|
|
delete components.needToLeave;
|
|
} else {
|
|
this.destroySubComponents(actorID);
|
|
}
|
|
}
|
|
|
|
if (!this.animations.length) {
|
|
this.emit("animation-timeline-rendering-completed");
|
|
return;
|
|
}
|
|
|
|
// Loop to set the time scale for all current animations.
|
|
TimeScale.reset();
|
|
for (const {state} of animations) {
|
|
TimeScale.addAnimation(state);
|
|
}
|
|
|
|
this.drawHeaderAndBackground();
|
|
|
|
for (const animation of this.animations) {
|
|
animation.on("changed", this.onAnimationStateChanged);
|
|
|
|
const tracks = await this.getTracks(animation);
|
|
// If we're destroyed by now, just give up.
|
|
if (this.isDestroyed) {
|
|
return;
|
|
}
|
|
|
|
if (this.componentsMap[animation.actorID]) {
|
|
// Update animation UI using existent components.
|
|
this.updateAnimation(animation, tracks, this.componentsMap[animation.actorID]);
|
|
} else {
|
|
// Render animation UI as new element.
|
|
const animationEl = createNode({
|
|
parent: this.animationsEl,
|
|
nodeType: "li",
|
|
});
|
|
this.renderAnimation(animation, tracks, animationEl);
|
|
}
|
|
}
|
|
|
|
// Use the document's current time to position the scrubber (if the server
|
|
// doesn't provide it, hide the scrubber entirely).
|
|
// Note that because the currentTime was sent via the protocol, some time
|
|
// may have gone by since then, and so the scrubber might be a bit late.
|
|
if (!documentCurrentTime) {
|
|
this.scrubberEl.style.display = "none";
|
|
} else {
|
|
this.scrubberEl.style.display = "block";
|
|
this.startAnimatingScrubber(this.wasRewound()
|
|
? TimeScale.minStartTime
|
|
: documentCurrentTime);
|
|
}
|
|
|
|
// To indicate the animation progress in AnimationDetails.
|
|
this.on("timeline-data-changed", this.onTimelineDataChanged);
|
|
|
|
if (this.animations.length === 1) {
|
|
// Display animation's detail if there is only one animation,
|
|
// even if the detail pane is closing.
|
|
await this.onAnimationSelected(this.animations[0]);
|
|
} else if (this.animationRootEl.classList.contains("animation-detail-visible") &&
|
|
this.animations.includes(this.selectedAnimation)) {
|
|
// animation's detail displays in case of the previously displayed animation is
|
|
// included in timeline list and the detail pane is not closing.
|
|
await this.onAnimationSelected(this.selectedAnimation);
|
|
} else {
|
|
// Otherwise, close detail pane.
|
|
this.onDetailCloseButtonClick();
|
|
}
|
|
this.emit("animation-timeline-rendering-completed");
|
|
},
|
|
|
|
updateAnimation: function(animation, tracks, existentComponents) {
|
|
// If keyframes (tracks) and effect timing (state) are not changed, we update the
|
|
// view box only.
|
|
// As an exception, if iterationCount reprensents Infinity, we need to re-render
|
|
// the shape along new time scale.
|
|
// FIXME: To avoid re-rendering even Infinity, we need to change the
|
|
// representation for Infinity.
|
|
if (animation.state.iterationCount &&
|
|
areTimingEffectsEqual(existentComponents.state, animation.state) &&
|
|
existentComponents.tracks.toString() === tracks.toString()) {
|
|
// Update timeBlock.
|
|
existentComponents.timeBlock.update(animation);
|
|
} else {
|
|
// Destroy previous components.
|
|
existentComponents.timeBlock.destroy();
|
|
existentComponents.targetNode.destroy();
|
|
// Remove children to re-use.
|
|
existentComponents.animationEl.innerHTML = "";
|
|
// Re-render animation using existent animationEl.
|
|
this.renderAnimation(animation, tracks, existentComponents.animationEl);
|
|
}
|
|
},
|
|
|
|
renderAnimation: function(animation, tracks, animationEl) {
|
|
animationEl.setAttribute("class",
|
|
"animation " + animation.state.type +
|
|
this.getCompositorStatusClassName(animation.state));
|
|
|
|
// Left sidebar for the animated node.
|
|
const animatedNodeEl = createNode({
|
|
parent: animationEl,
|
|
attributes: {
|
|
"class": "target"
|
|
}
|
|
});
|
|
|
|
// Draw the animated node target.
|
|
const targetNode = new AnimationTargetNode(this.inspector, {compact: true});
|
|
targetNode.init(animatedNodeEl);
|
|
targetNode.render(animation);
|
|
|
|
// Right-hand part contains the timeline itself (called time-block here).
|
|
const timeBlockEl = createNode({
|
|
parent: animationEl,
|
|
attributes: {
|
|
"class": "time-block track-container"
|
|
}
|
|
});
|
|
|
|
// Draw the animation time block.
|
|
const timeBlock = new AnimationTimeBlock();
|
|
timeBlock.init(timeBlockEl);
|
|
timeBlock.render(animation, tracks);
|
|
timeBlock.on("selected", this.onAnimationSelected);
|
|
|
|
this.componentsMap[animation.actorID] = {
|
|
animationEl, targetNode, timeBlock, tracks, state: animation.state
|
|
};
|
|
},
|
|
|
|
isAtLeastOneAnimationPlaying: function() {
|
|
return this.animations.some(({state}) => state.playState === "running");
|
|
},
|
|
|
|
wasRewound: function() {
|
|
return !this.isAtLeastOneAnimationPlaying() &&
|
|
this.animations.every(({state}) => state.currentTime === 0);
|
|
},
|
|
|
|
hasInfiniteAnimations: function() {
|
|
return this.animations.some(({state}) => !state.iterationCount);
|
|
},
|
|
|
|
startAnimatingScrubber: function(time) {
|
|
const isOutOfBounds = time < TimeScale.minStartTime ||
|
|
time > TimeScale.maxEndTime;
|
|
const isAllPaused = !this.isAtLeastOneAnimationPlaying();
|
|
const hasInfinite = this.hasInfiniteAnimations();
|
|
|
|
let x = TimeScale.startTimeToDistance(time);
|
|
if (x > 100 && !hasInfinite) {
|
|
x = 100;
|
|
}
|
|
this.scrubberEl.style.left = x + "%";
|
|
|
|
// Only stop the scrubber if it's out of bounds or all animations have been
|
|
// paused, but not if at least an animation is infinite.
|
|
if (isAllPaused || (isOutOfBounds && !hasInfinite)) {
|
|
this.stopAnimatingScrubber();
|
|
this.emit("timeline-data-changed", {
|
|
isPaused: !this.isAtLeastOneAnimationPlaying(),
|
|
isMoving: false,
|
|
isUserDrag: false,
|
|
time: TimeScale.distanceToRelativeTime(x)
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.emit("timeline-data-changed", {
|
|
isPaused: false,
|
|
isMoving: true,
|
|
isUserDrag: false,
|
|
time: TimeScale.distanceToRelativeTime(x)
|
|
});
|
|
|
|
const now = this.win.performance.now();
|
|
this.rafID = this.win.requestAnimationFrame(() => {
|
|
if (!this.rafID) {
|
|
// In case the scrubber was stopped in the meantime.
|
|
return;
|
|
}
|
|
this.startAnimatingScrubber(time + this.win.performance.now() - now);
|
|
});
|
|
},
|
|
|
|
stopAnimatingScrubber: function() {
|
|
if (this.rafID) {
|
|
this.win.cancelAnimationFrame(this.rafID);
|
|
this.rafID = null;
|
|
}
|
|
},
|
|
|
|
onAnimationStateChanged: function() {
|
|
// For now, simply re-render the component. The animation front's state has
|
|
// already been updated.
|
|
this.render(this.animations);
|
|
},
|
|
|
|
drawHeaderAndBackground: function() {
|
|
const width = this.timeHeaderEl.offsetWidth;
|
|
const animationDuration = TimeScale.maxEndTime - TimeScale.minStartTime;
|
|
const minTimeInterval = TIME_GRADUATION_MIN_SPACING *
|
|
animationDuration / width;
|
|
const intervalLength = findOptimalTimeInterval(minTimeInterval);
|
|
const intervalWidth = intervalLength * width / animationDuration;
|
|
|
|
// And the time graduation header.
|
|
this.timeHeaderEl.innerHTML = "";
|
|
this.timeTickEl.innerHTML = "";
|
|
|
|
for (let i = 0; i <= width / intervalWidth; i++) {
|
|
const pos = 100 * i * intervalWidth / width;
|
|
|
|
// This element is the header of time tick for displaying animation
|
|
// duration time.
|
|
createNode({
|
|
parent: this.timeHeaderEl,
|
|
nodeType: "span",
|
|
attributes: {
|
|
"class": "header-item",
|
|
"style": `left:${pos}%`
|
|
},
|
|
textContent: TimeScale.formatTime(TimeScale.distanceToRelativeTime(pos))
|
|
});
|
|
|
|
// This element is displayed as a vertical line separator corresponding
|
|
// the header of time tick for indicating time slice for animation
|
|
// iterations.
|
|
createNode({
|
|
parent: this.timeTickEl,
|
|
nodeType: "span",
|
|
attributes: {
|
|
"class": "time-tick",
|
|
"style": `left:${pos}%`
|
|
}
|
|
});
|
|
}
|
|
},
|
|
|
|
onTimelineDataChanged: function({ time }) {
|
|
this.currentTime = time;
|
|
const indicateTime =
|
|
TimeScale.minStartTime === Infinity ? 0 : this.currentTime + TimeScale.minStartTime;
|
|
this.details.indicateProgress(indicateTime);
|
|
},
|
|
|
|
onDetailCloseButtonClick: function(e) {
|
|
if (!this.animationRootEl.classList.contains("animation-detail-visible")) {
|
|
return;
|
|
}
|
|
this.animationRootEl.classList.remove("animation-detail-visible");
|
|
this.emit("animation-detail-closed");
|
|
},
|
|
|
|
/**
|
|
* Get a list of the tracks of the animation actor
|
|
* @param {Object} animation
|
|
* @return {Object} A list of tracks, one per animated property, each
|
|
* with a list of keyframes
|
|
*/
|
|
async getTracks(animation) {
|
|
const tracks = {};
|
|
|
|
/*
|
|
* getFrames is a AnimationPlayorActor method that returns data about the
|
|
* keyframes of the animation.
|
|
* In FF48, the data it returns change, and will hold only longhand
|
|
* properties ( e.g. borderLeftWidth ), which does not match what we
|
|
* want to display in the animation detail.
|
|
* A new AnimationPlayerActor function, getProperties, is introduced,
|
|
* that returns the animated css properties of the animation and their
|
|
* keyframes values.
|
|
* If the animation actor has the getProperties function, we use it, and if
|
|
* not, we fall back to getFrames, which then returns values we used to
|
|
* handle.
|
|
*/
|
|
if (this.serverTraits.hasGetProperties) {
|
|
let properties = [];
|
|
try {
|
|
properties = await animation.getProperties();
|
|
} catch (e) {
|
|
// Expected if we've already been destroyed in the meantime.
|
|
if (!this.isDestroyed) {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
for (const {name, values} of properties) {
|
|
if (!tracks[name]) {
|
|
tracks[name] = [];
|
|
}
|
|
|
|
for (let {value, offset, easing, distance} of values) {
|
|
distance = distance ? distance : 0;
|
|
offset = parseFloat(offset.toFixed(3));
|
|
tracks[name].push({value, offset, easing, distance});
|
|
}
|
|
}
|
|
} else {
|
|
let frames = [];
|
|
try {
|
|
frames = await animation.getFrames();
|
|
} catch (e) {
|
|
// Expected if we've already been destroyed in the meantime.
|
|
if (!this.isDestroyed) {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
for (const frame of frames) {
|
|
for (const name in frame) {
|
|
if (this.NON_PROPERTIES.includes(name)) {
|
|
continue;
|
|
}
|
|
|
|
// We have to change to CSS property name
|
|
// since GetKeyframes returns JS property name.
|
|
const propertyCSSName = getCssPropertyName(name);
|
|
if (!tracks[propertyCSSName]) {
|
|
tracks[propertyCSSName] = [];
|
|
}
|
|
|
|
tracks[propertyCSSName].push({
|
|
value: frame[name],
|
|
offset: parseFloat(frame.computedOffset.toFixed(3)),
|
|
easing: frame.easing,
|
|
distance: 0
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return tracks;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Check the equality given states as effect timing.
|
|
* @param {Object} state of animation.
|
|
* @param {Object} same to avobe.
|
|
* @return {boolean} true: same effect timing
|
|
*/
|
|
function areTimingEffectsEqual(stateA, stateB) {
|
|
for (const property of ["playbackRate", "duration", "delay", "endDelay",
|
|
"iterationCount", "iterationStart", "easing",
|
|
"fill", "direction"]) {
|
|
if (stateA[property] !== stateB[property]) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|