--- a/devtools/client/inspector/animation/animation.js
+++ b/devtools/client/inspector/animation/animation.js
@@ -13,20 +13,22 @@ const EventEmitter = require("devtools/s
const App = createFactory(require("./components/App"));
const { updateAnimations } = require("./actions/animations");
const { updateElementPickerEnabled } = require("./actions/element-picker");
const { updateSidebarSize } = require("./actions/sidebar");
const { isAllAnimationEqual } = require("./utils/utils");
class AnimationInspector {
- constructor(inspector) {
+ constructor(inspector, win) {
this.inspector = inspector;
+ this.win = win;
this.getNodeFromActor = this.getNodeFromActor.bind(this);
+ this.simulateAnimation = this.simulateAnimation.bind(this);
this.toggleElementPicker = this.toggleElementPicker.bind(this);
this.update = this.update.bind(this);
this.onElementPickerStarted = this.onElementPickerStarted.bind(this);
this.onElementPickerStopped = this.onElementPickerStopped.bind(this);
this.onSidebarResized = this.onSidebarResized.bind(this);
this.onSidebarSelect = this.onSidebarSelect.bind(this);
EventEmitter.decorate(this);
@@ -43,16 +45,17 @@ class AnimationInspector {
const {
onHideBoxModelHighlighter,
} = this.inspector.getPanel("boxmodel").getComponentProps();
const {
emit: emitEventForTest,
getNodeFromActor,
+ simulateAnimation,
toggleElementPicker,
} = this;
const target = this.inspector.target;
this.animationsFront = new AnimationsFront(target.client, target.form);
const provider = createElement(Provider,
{
@@ -62,16 +65,17 @@ class AnimationInspector {
},
App(
{
emitEventForTest,
getNodeFromActor,
onHideBoxModelHighlighter,
onShowBoxModelHighlighterForNode,
setSelectedNode,
+ simulateAnimation,
toggleElementPicker,
}
)
);
this.provider = provider;
this.inspector.selection.on("new-node-front", this.update);
this.inspector.sidebar.on("newanimationinspector-selected", this.onSidebarSelect);
@@ -82,17 +86,28 @@ class AnimationInspector {
destroy() {
this.inspector.selection.off("new-node-front", this.update);
this.inspector.sidebar.off("newanimationinspector-selected", this.onSidebarSelect);
this.inspector.toolbox.off("inspector-sidebar-resized", this.onSidebarResized);
this.inspector.toolbox.off("picker-started", this.onElementPickerStarted);
this.inspector.toolbox.off("picker-stopped", this.onElementPickerStopped);
+ if (this.simulatedAnimation) {
+ this.simulatedAnimation.cancel();
+ this.simulatedAnimation = null;
+ }
+
+ if (this.simulatedElement) {
+ this.simulatedElement.remove();
+ this.simulatedElement = null;
+ }
+
this.inspector = null;
+ this.win = null;
}
/**
* Return a map of animated property from given animation actor.
*
* @param {Object} animation
* @return {Map} A map of animated property
* key: {String} Animated property name
@@ -134,16 +149,58 @@ class AnimationInspector {
}
isPanelVisible() {
return this.inspector && this.inspector.toolbox && this.inspector.sidebar &&
this.inspector.toolbox.currentToolId === "inspector" &&
this.inspector.sidebar.getCurrentTabID() === "newanimationinspector";
}
+ /**
+ * Returns simulatable animation by given parameters.
+ * The returned animation is implementing Animation interface of Web Animation API.
+ * https://drafts.csswg.org/web-animations/#the-animation-interface
+ *
+ * @param {Array} keyframes
+ * e.g. [{ opacity: 0 }, { opacity: 1 }]
+ * @param {Object} effectTiming
+ * e.g. { duration: 1000, fill: "both" }
+ * @param {Boolean} isElementNeeded
+ * true: create animation with an element.
+ * If want to know computed value of the element, turn on.
+ * false: create animation without an element,
+ * If need to know only timing progress.
+ * @return {Animation}
+ * https://drafts.csswg.org/web-animations/#the-animation-interface
+ */
+ simulateAnimation(keyframes, effectTiming, isElementNeeded) {
+ let targetEl = null;
+
+ if (isElementNeeded) {
+ if (!this.simulatedElement) {
+ this.simulatedElement = this.win.document.createElement("div");
+ this.win.document.documentElement.appendChild(this.simulatedElement);
+ } else {
+ // Reset styles.
+ this.simulatedElement.style.cssText = "";
+ }
+
+ targetEl = this.simulatedElement;
+ }
+
+ if (!this.simulatedAnimation) {
+ this.simulatedAnimation = new this.win.Animation();
+ }
+
+ this.simulatedAnimation.effect =
+ new this.win.KeyframeEffect(targetEl, keyframes, effectTiming);
+
+ return this.simulatedAnimation;
+ }
+
toggleElementPicker() {
this.inspector.toolbox.highlighterUtils.togglePicker();
}
async update() {
if (!this.inspector || !this.isPanelVisible()) {
// AnimationInspector was destroyed already or the panel is hidden.
return;
--- a/devtools/client/inspector/animation/components/AnimationItem.js
+++ b/devtools/client/inspector/animation/components/AnimationItem.js
@@ -15,28 +15,30 @@ class AnimationItem extends PureComponen
static get propTypes() {
return {
animation: PropTypes.object.isRequired,
emitEventForTest: PropTypes.func.isRequired,
getNodeFromActor: PropTypes.func.isRequired,
onHideBoxModelHighlighter: PropTypes.func.isRequired,
onShowBoxModelHighlighterForNode: PropTypes.func.isRequired,
setSelectedNode: PropTypes.func.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
timeScale: PropTypes.object.isRequired,
};
}
render() {
const {
animation,
emitEventForTest,
getNodeFromActor,
onHideBoxModelHighlighter,
onShowBoxModelHighlighterForNode,
setSelectedNode,
+ simulateAnimation,
timeScale,
} = this.props;
return dom.li(
{
className: "animation-item"
},
AnimationTarget(
@@ -47,16 +49,17 @@ class AnimationItem extends PureComponen
onHideBoxModelHighlighter,
onShowBoxModelHighlighterForNode,
setSelectedNode,
}
),
SummaryGraph(
{
animation,
+ simulateAnimation,
timeScale,
}
)
);
}
}
module.exports = AnimationItem;
--- a/devtools/client/inspector/animation/components/AnimationList.js
+++ b/devtools/client/inspector/animation/components/AnimationList.js
@@ -14,44 +14,47 @@ class AnimationList extends PureComponen
static get propTypes() {
return {
animations: PropTypes.arrayOf(PropTypes.object).isRequired,
emitEventForTest: PropTypes.func.isRequired,
getNodeFromActor: PropTypes.func.isRequired,
onHideBoxModelHighlighter: PropTypes.func.isRequired,
onShowBoxModelHighlighterForNode: PropTypes.func.isRequired,
setSelectedNode: PropTypes.func.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
timeScale: PropTypes.object.isRequired,
};
}
render() {
const {
animations,
emitEventForTest,
getNodeFromActor,
onHideBoxModelHighlighter,
onShowBoxModelHighlighterForNode,
setSelectedNode,
+ simulateAnimation,
timeScale,
} = this.props;
return dom.ul(
{
className: "animation-list"
},
animations.map(animation =>
AnimationItem(
{
animation,
emitEventForTest,
getNodeFromActor,
onHideBoxModelHighlighter,
onShowBoxModelHighlighterForNode,
setSelectedNode,
+ simulateAnimation,
timeScale,
}
)
)
);
}
}
--- a/devtools/client/inspector/animation/components/AnimationListContainer.js
+++ b/devtools/client/inspector/animation/components/AnimationListContainer.js
@@ -18,27 +18,29 @@ class AnimationListContainer extends Pur
static get propTypes() {
return {
animations: PropTypes.arrayOf(PropTypes.object).isRequired,
emitEventForTest: PropTypes.func.isRequired,
getNodeFromActor: PropTypes.func.isRequired,
onHideBoxModelHighlighter: PropTypes.func.isRequired,
onShowBoxModelHighlighterForNode: PropTypes.func.isRequired,
setSelectedNode: PropTypes.func.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
};
}
render() {
const {
animations,
emitEventForTest,
getNodeFromActor,
onHideBoxModelHighlighter,
onShowBoxModelHighlighterForNode,
setSelectedNode,
+ simulateAnimation,
} = this.props;
const timeScale = new TimeScale(animations);
return dom.div(
{
className: "animation-list-container"
},
AnimationListHeader(
@@ -49,16 +51,17 @@ class AnimationListContainer extends Pur
AnimationList(
{
animations,
emitEventForTest,
getNodeFromActor,
onHideBoxModelHighlighter,
onShowBoxModelHighlighterForNode,
setSelectedNode,
+ simulateAnimation,
timeScale,
}
)
);
}
}
module.exports = AnimationListContainer;
--- a/devtools/client/inspector/animation/components/App.js
+++ b/devtools/client/inspector/animation/components/App.js
@@ -16,48 +16,51 @@ class App extends PureComponent {
static get propTypes() {
return {
animations: PropTypes.arrayOf(PropTypes.object).isRequired,
emitEventForTest: PropTypes.func.isRequired,
getNodeFromActor: PropTypes.func.isRequired,
onHideBoxModelHighlighter: PropTypes.func.isRequired,
onShowBoxModelHighlighterForNode: PropTypes.func.isRequired,
setSelectedNode: PropTypes.func.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
toggleElementPicker: PropTypes.func.isRequired,
};
}
shouldComponentUpdate(nextProps, nextState) {
return this.props.animations.length !== 0 || nextProps.animations.length !== 0;
}
render() {
const {
animations,
emitEventForTest,
getNodeFromActor,
onHideBoxModelHighlighter,
onShowBoxModelHighlighterForNode,
setSelectedNode,
+ simulateAnimation,
toggleElementPicker,
} = this.props;
return dom.div(
{
id: "animation-container"
},
animations.length ?
AnimationListContainer(
{
animations,
emitEventForTest,
getNodeFromActor,
onHideBoxModelHighlighter,
onShowBoxModelHighlighterForNode,
setSelectedNode,
+ simulateAnimation,
}
)
:
NoAnimationPanel(
{
toggleElementPicker
}
)
--- a/devtools/client/inspector/animation/components/graph/ComputedTimingPath.js
+++ b/devtools/client/inspector/animation/components/graph/ComputedTimingPath.js
@@ -1,26 +1,94 @@
/* 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 { PureComponent } = require("devtools/client/shared/vendor/react");
const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
const dom = require("devtools/client/shared/vendor/react-dom-factories");
-class ComputedTimingPath extends PureComponent {
+const { SummaryGraphHelper, toPathString } = require("../../utils/graph-helper");
+const TimingPath = require("./TimingPath");
+
+class ComputedTimingPath extends TimingPath {
static get propTypes() {
return {
animation: PropTypes.object.isRequired,
durationPerPixel: PropTypes.number.isRequired,
keyframes: PropTypes.object.isRequired,
- totalDisplayedDuration: PropTypes.number.isRequired,
+ opacity: PropTypes.number.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
+ totalDuration: PropTypes.number.isRequired,
};
}
render() {
- return dom.g({});
+ const {
+ animation,
+ durationPerPixel,
+ keyframes,
+ opacity,
+ simulateAnimation,
+ totalDuration,
+ } = this.props;
+
+ const { state } = animation;
+ const effectTiming = Object.assign({}, state, {
+ iterations: state.iterationCount ? state.iterationCount : Infinity
+ });
+
+ // Create new keyframes for opacity as computed style.
+ // The reason why we use computed value instead of computed timing progress is to
+ // include the easing in keyframes as well. Although the computed timing progress
+ // is not affected by the easing in keyframes at all, computed value reflects that.
+ const frames = keyframes.map(keyframe => {
+ return {
+ opacity: keyframe.offset,
+ offset: keyframe.offset,
+ easing: keyframe.easing
+ };
+ });
+ const simulatedAnimation = simulateAnimation(frames, effectTiming, true);
+ const simulatedElement = simulatedAnimation.effect.target;
+ const win = simulatedElement.ownerGlobal;
+ const endTime = simulatedAnimation.effect.getComputedTiming().endTime;
+
+ // Set the underlying opacity to zero so that if we sample the animation's output
+ // during the delay phase and it is not filling backwards, we get zero.
+ simulatedElement.style.opacity = 0;
+
+ const getValueFunc = time => {
+ if (time < 0) {
+ return { x: time, y: 0 };
+ }
+
+ simulatedAnimation.currentTime = time < endTime ? time : endTime;
+ return win.getComputedStyle(simulatedElement).opacity;
+ };
+
+ const toPathStringFunc = segments => {
+ const firstSegment = segments[0];
+ let pathString = `M${ firstSegment.x },0 `;
+ pathString += toPathString(segments);
+ const lastSegment = segments[segments.length - 1];
+ pathString += `L${ lastSegment.x },0 Z`;
+ return pathString;
+ };
+
+ const helper = new SummaryGraphHelper(state, keyframes,
+ totalDuration, durationPerPixel,
+ getValueFunc, toPathStringFunc);
+ const offset = state.previousStartTime ? state.previousStartTime : 0;
+
+ return dom.g(
+ {
+ className: "animation-computed-timing-path",
+ style: { opacity },
+ transform: `translate(${ offset })`
+ },
+ super.renderGraph(state, helper)
+ );
}
}
module.exports = ComputedTimingPath;
--- a/devtools/client/inspector/animation/components/graph/SummaryGraph.js
+++ b/devtools/client/inspector/animation/components/graph/SummaryGraph.js
@@ -9,33 +9,36 @@ const dom = require("devtools/client/sha
const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
const SummaryGraphPath = createFactory(require("./SummaryGraphPath"));
class SummaryGraph extends PureComponent {
static get propTypes() {
return {
animation: PropTypes.object.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
timeScale: PropTypes.object.isRequired,
};
}
render() {
const {
animation,
+ simulateAnimation,
timeScale,
} = this.props;
return dom.div(
{
className: "animation-summary-graph",
},
SummaryGraphPath(
{
animation,
+ simulateAnimation,
timeScale,
}
)
);
}
}
module.exports = SummaryGraph;
--- a/devtools/client/inspector/animation/components/graph/SummaryGraphPath.js
+++ b/devtools/client/inspector/animation/components/graph/SummaryGraphPath.js
@@ -5,21 +5,24 @@
"use strict";
const { createFactory, PureComponent } = require("devtools/client/shared/vendor/react");
const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
const dom = require("devtools/client/shared/vendor/react-dom-factories");
const ReactDOM = require("devtools/client/shared/vendor/react-dom");
const ComputedTimingPath = createFactory(require("./ComputedTimingPath"));
+// Minimum opacity for semitransparent fill color for keyframes's easing graph.
+const MIN_KEYFRAMES_EASING_OPACITY = 0.5;
class SummaryGraphPath extends PureComponent {
static get propTypes() {
return {
animation: PropTypes.object.isRequired,
+ simulateAnimation: PropTypes.func.isRequired,
timeScale: PropTypes.object.isRequired,
};
}
constructor(props) {
super(props);
this.state = {
@@ -141,36 +144,40 @@ class SummaryGraphPath extends PureCompo
const { durationPerPixel } = this.state;
if (!durationPerPixel) {
return dom.svg();
}
const {
animation,
+ simulateAnimation,
timeScale,
} = this.props;
const totalDuration = this.getTotalDuration(animation, timeScale);
const startTime = timeScale.minStartTime;
const keyframesList =
this.getOffsetAndEasingOnlyKeyframes(animation.animatedPropertyMap);
+ const opacity = Math.max(1 / keyframesList.length, MIN_KEYFRAMES_EASING_OPACITY);
return dom.svg(
{
className: "animation-summary-graph-path",
preserveAspectRatio: "none",
viewBox: `${ startTime } -1 ${ totalDuration } 1`
},
keyframesList.map(keyframes =>
ComputedTimingPath(
{
animation,
durationPerPixel,
keyframes,
+ opacity,
+ simulateAnimation,
totalDuration,
}
)
)
);
}
}
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/animation/components/graph/TimingPath.js
@@ -0,0 +1,349 @@
+/* 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 { PureComponent } = require("devtools/client/shared/vendor/react");
+const dom = require("devtools/client/shared/vendor/react-dom-factories");
+
+// Show max 10 iterations for infinite animations
+// to give users a clue that the animation does repeat.
+const MAX_INFINITE_ANIMATIONS_ITERATIONS = 10;
+
+class TimingPath extends PureComponent {
+ /**
+ * Render a graph of given parameters and return as <path> element list.
+ *
+ * @param {Object} state
+ * State of animation.
+ * @param {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ * @return {Array}
+ * list of <path> element.
+ */
+ renderGraph(state, helper) {
+ // Starting time of main iteration.
+ let mainIterationStartTime = 0;
+ let iterationStart = state.iterationStart;
+ let iterationCount = state.iterationCount ? state.iterationCount : Infinity;
+
+ const pathList = [];
+
+ // Append delay.
+ if (state.delay > 0) {
+ this.renderDelay(pathList, state, helper);
+ mainIterationStartTime = state.delay;
+ } else {
+ const negativeDelayCount = -state.delay / state.duration;
+ // Move to forward the starting point for negative delay.
+ iterationStart += negativeDelayCount;
+ // Consume iteration count by negative delay.
+ if (iterationCount !== Infinity) {
+ iterationCount -= negativeDelayCount;
+ }
+ }
+
+ // Append 1st section of iterations,
+ // This section is only useful in cases where iterationStart has decimals.
+ // e.g.
+ // if { iterationStart: 0.25, iterations: 3 }, firstSectionCount is 0.75.
+ const firstSectionCount = iterationStart % 1 === 0
+ ? 0
+ : Math.min(iterationCount, 1) - iterationStart % 1;
+ if (firstSectionCount) {
+ this.renderFirstIteration(pathList, state,
+ mainIterationStartTime, firstSectionCount, helper);
+ }
+
+ if (iterationCount === Infinity) {
+ // If the animation repeats infinitely,
+ // we fill the remaining area with iteration paths.
+ this.renderInfinity(pathList, state,
+ mainIterationStartTime, firstSectionCount, helper);
+ } else {
+ // Otherwise, we show remaining iterations, endDelay and fill.
+
+ // Append forwards fill-mode.
+ if (state.fill === "both" || state.fill === "forwards") {
+ this.renderForwardsFill(pathList, state,
+ mainIterationStartTime, iterationCount, helper);
+ }
+
+ // Append middle section of iterations.
+ // e.g.
+ // if { iterationStart: 0.25, iterations: 3 }, middleSectionCount is 2.
+ const middleSectionCount = Math.floor(iterationCount - firstSectionCount);
+ this.renderMiddleIterations(pathList, state, mainIterationStartTime,
+ firstSectionCount, middleSectionCount, helper);
+
+ // Append last section of iterations, if there is remaining iteration.
+ // e.g.
+ // if { iterationStart: 0.25, iterations: 3 }, lastSectionCount is 0.25.
+ const lastSectionCount = iterationCount - middleSectionCount - firstSectionCount;
+ if (lastSectionCount) {
+ this.renderLastIteration(pathList, state, mainIterationStartTime,
+ firstSectionCount, middleSectionCount,
+ lastSectionCount, helper);
+ }
+
+ // Append endDelay.
+ if (state.endDelay > 0) {
+ this.renderEndDelay(pathList, state,
+ mainIterationStartTime, iterationCount, helper);
+ }
+ }
+ return pathList;
+ }
+
+ /**
+ * Render 'delay' part in animation and add a <path> element to given pathList.
+ *
+ * @param {Array} pathList
+ * Add rendered <path> element to this array.
+ * @param {Object} state
+ * State of animation.
+ * @param {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ */
+ renderDelay(pathList, state, helper) {
+ const startSegment = helper.getSegment(0);
+ const endSegment = { x: state.delay, y: startSegment.y };
+ const segments = [startSegment, endSegment];
+ pathList.push(
+ dom.path(
+ {
+ className: "animation-delay-path",
+ d: helper.toPathString(segments),
+ }
+ )
+ );
+ }
+
+ /**
+ * Render 1st section of iterations and add a <path> element to given pathList.
+ * This section is only useful in cases where iterationStart has decimals.
+ *
+ * @param {Array} pathList
+ * Add rendered <path> element to this array.
+ * @param {Object} state
+ * State of animation.
+ * @param {Number} mainIterationStartTime
+ * Start time of main iteration.
+ * @param {Number} firstSectionCount
+ * Iteration count of first section.
+ * @param {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ */
+ renderFirstIteration(pathList, state, mainIterationStartTime,
+ firstSectionCount, helper) {
+ const startTime = mainIterationStartTime;
+ const endTime = startTime + firstSectionCount * state.duration;
+ const segments = helper.createPathSegments(startTime, endTime);
+ pathList.push(
+ dom.path(
+ {
+ className: "animation-iteration-path",
+ d: helper.toPathString(segments),
+ }
+ )
+ );
+ }
+
+ /**
+ * Render middle iterations and add <path> elements to given pathList.
+ *
+ * @param {Array} pathList
+ * Add rendered <path> elements to this array.
+ * @param {Object} state
+ * State of animation.
+ * @param {Number} mainIterationStartTime
+ * Starting time of main iteration.
+ * @param {Number} firstSectionCount
+ * Iteration count of first section.
+ * @param {Number} middleSectionCount
+ * Iteration count of middle section.
+ * @param {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ */
+ renderMiddleIterations(pathList, state, mainIterationStartTime,
+ firstSectionCount, middleSectionCount, helper) {
+ const offset = mainIterationStartTime + firstSectionCount * state.duration;
+ for (let i = 0; i < middleSectionCount; i++) {
+ // Get the path segments of each iteration.
+ const startTime = offset + i * state.duration;
+ const endTime = startTime + state.duration;
+ const segments = helper.createPathSegments(startTime, endTime);
+ pathList.push(
+ dom.path(
+ {
+ className: "animation-iteration-path",
+ d: helper.toPathString(segments),
+ }
+ )
+ );
+ }
+ }
+
+ /**
+ * Render last section of iterations and add a <path> element to given pathList.
+ * This section is only useful in cases where iterationStart has decimals.
+ *
+ * @param {Array} pathList
+ * Add rendered <path> elements to this array.
+ * @param {Object} state
+ * State of animation.
+ * @param {Number} mainIterationStartTime
+ * Starting time of main iteration.
+ * @param {Number} firstSectionCount
+ * Iteration count of first section.
+ * @param {Number} middleSectionCount
+ * Iteration count of middle section.
+ * @param {Number} lastSectionCount
+ * Iteration count of last section.
+ * @param {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ */
+ renderLastIteration(pathList, state, mainIterationStartTime, firstSectionCount,
+ middleSectionCount, lastSectionCount, helper) {
+ const startTime = mainIterationStartTime
+ + (firstSectionCount + middleSectionCount) * state.duration;
+ const endTime = startTime + lastSectionCount * state.duration;
+ const segments = helper.createPathSegments(startTime, endTime);
+ pathList.push(
+ dom.path(
+ {
+ className: "animation-iteration-path",
+ d: helper.toPathString(segments),
+ }
+ )
+ );
+ }
+
+ /**
+ * Render infinity iterations and add <path> elements to given pathList.
+ *
+ * @param {Array} pathList
+ * Add rendered <path> elements to this array.
+ * @param {Object} state
+ * State of animation.
+ * @param {Number} mainIterationStartTime
+ * Starting time of main iteration.
+ * @param {Number} firstSectionCount
+ * Iteration count of first section.
+ * @param {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ */
+ renderInfinity(pathList, state, mainIterationStartTime, firstSectionCount, helper) {
+ // Calculate the number of iterations to display,
+ // with a maximum of MAX_INFINITE_ANIMATIONS_ITERATIONS
+ let uncappedInfinityIterationCount =
+ (helper.totalDuration - firstSectionCount * state.duration) / state.duration;
+ // If there is a small floating point error resulting in, e.g. 1.0000001
+ // ceil will give us 2 so round first.
+ uncappedInfinityIterationCount =
+ parseFloat(uncappedInfinityIterationCount.toPrecision(6));
+ const infinityIterationCount = Math.min(MAX_INFINITE_ANIMATIONS_ITERATIONS,
+ Math.ceil(uncappedInfinityIterationCount));
+
+ // Append first full iteration path.
+ const firstStartTime =
+ mainIterationStartTime + firstSectionCount * state.duration;
+ const firstEndTime = firstStartTime + state.duration;
+ const firstSegments = helper.createPathSegments(firstStartTime, firstEndTime);
+ pathList.push(
+ dom.path(
+ {
+ className: "animation-iteration-path",
+ d: helper.toPathString(firstSegments),
+ }
+ )
+ );
+
+ // Append other iterations. We can copy first segments.
+ const isAlternate = state.direction.match(/alternate/);
+ for (let i = 1; i < infinityIterationCount; i++) {
+ const startTime = firstStartTime + i * state.duration;
+ let segments;
+ if (isAlternate && i % 2) {
+ // Copy as reverse.
+ segments = firstSegments.map(segment => {
+ return { x: firstEndTime - segment.x + startTime, y: segment.y };
+ });
+ } else {
+ // Copy as is.
+ segments = firstSegments.map(segment => {
+ return { x: segment.x - firstStartTime + startTime, y: segment.y };
+ });
+ }
+ pathList.push(
+ dom.path(
+ {
+ className: "animation-iteration-path infinity",
+ d: helper.toPathString(segments),
+ }
+ )
+ );
+ }
+ }
+
+ /**
+ * Render 'endDelay' part in animation and add a <path> element to given pathList.
+ *
+ * @param {Array} pathList
+ * Add rendered <path> element to this array.
+ * @param {Object} state
+ * State of animation.
+ * @param {Number} mainIterationStartTime
+ * Starting time of main iteration.
+ * @param {Number} iterationCount
+ * Iteration count of whole animation.
+ * @param {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ */
+ renderEndDelay(pathList, state, mainIterationStartTime, iterationCount, helper) {
+ const startTime = mainIterationStartTime + iterationCount * state.duration;
+ const startSegment = helper.getSegment(startTime);
+ const endSegment = { x: startTime + state.endDelay, y: startSegment.y };
+ pathList.push(
+ dom.path(
+ {
+ className: "animation-enddelay-path",
+ d: helper.toPathString([startSegment, endSegment]),
+ }
+ )
+ );
+ }
+
+ /**
+ * Render 'fill' for forwards part in animation and
+ * add a <path> element to given pathList.
+ *
+ * @param {Array} pathList
+ * Add rendered <path> element to this array.
+ * @param {Object} state
+ * State of animation.
+ * @param {Number} mainIterationStartTime
+ * Starting time of main iteration.
+ * @param {Number} iterationCount
+ * Iteration count of whole animation.
+ * @param {SummaryGraphHelper} helper
+ * Instance of SummaryGraphHelper.
+ */
+ renderForwardsFill(pathList, state, mainIterationStartTime, iterationCount, helper) {
+ const startTime = mainIterationStartTime + iterationCount * state.duration
+ + (state.endDelay > 0 ? state.endDelay : 0);
+ const startSegment = helper.getSegment(startTime);
+ const endSegment = { x: helper.totalDuration, y: startSegment.y };
+ pathList.push(
+ dom.path(
+ {
+ className: "animation-fill-forwards-path",
+ d: helper.toPathString([startSegment, endSegment]),
+ }
+ )
+ );
+ }
+}
+
+module.exports = TimingPath;
--- a/devtools/client/inspector/animation/components/graph/moz.build
+++ b/devtools/client/inspector/animation/components/graph/moz.build
@@ -1,9 +1,10 @@
# 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/.
DevToolsModules(
'ComputedTimingPath.js',
'SummaryGraph.js',
- 'SummaryGraphPath.js'
+ 'SummaryGraphPath.js',
+ 'TimingPath.js'
)
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/animation/utils/graph-helper.js
@@ -0,0 +1,239 @@
+/* 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";
+
+// BOUND_EXCLUDING_TIME should be less than 1ms and is used to exclude start
+// and end bounds when dividing duration in createPathSegments.
+const BOUND_EXCLUDING_TIME = 0.001;
+// DEFAULT_MIN_PROGRESS_THRESHOLD shoud be between more than 0 to 1.
+const DEFAULT_MIN_PROGRESS_THRESHOLD = 0.1;
+// In the createPathSegments function, an animation duration is divided by
+// DURATION_RESOLUTION in order to draw the way the animation progresses.
+// But depending on the timing-function, we may be not able to make the graph
+// smoothly progress if this resolution is not high enough.
+// So, if the difference of animation progress between 2 divisions is more than
+// DEFAULT_MIN_PROGRESS_THRESHOLD, then createPathSegments re-divides
+// by DURATION_RESOLUTION.
+// DURATION_RESOLUTION shoud be integer and more than 2.
+const DURATION_RESOLUTION = 4;
+
+/**
+ * The helper class for creating summary graph.
+ */
+class SummaryGraphHelper {
+ /**
+ * Constructor.
+ *
+ * @param {Object} state
+ * State of animation.
+ * @param {Array} keyframes
+ * Array of keyframe.
+ * @param {Number} totalDuration
+ * Total displayable duration.
+ * @param {Number} minSegmentDuration
+ * Minimum segment duration.
+ * @param {Function} getValueFunc
+ * Which returns graph value of given time.
+ * The function should return a number value between 0 - 1.
+ * e.g. time => { return 1.0 };
+ * @param {Function} toPathStringFunc
+ * Which returns a path string for 'd' attribute for <path> from given segments.
+ */
+ constructor(state, keyframes, totalDuration, minSegmentDuration,
+ getValueFunc, toPathStringFunc) {
+ this.totalDuration = totalDuration;
+ this.minSegmentDuration = minSegmentDuration;
+ this.minProgressThreshold = getPreferredProgressThreshold(state, keyframes);
+ this.durationResolution = getPreferredDurationResolution(keyframes);
+ this.getValue = getValueFunc;
+ this.toPathString = toPathStringFunc;
+
+ this.getSegment = this.getSegment.bind(this);
+ }
+
+ /**
+ * Create the path segments from given parameters.
+ *
+ * @param {Number} startTime
+ * Starting time of animation.
+ * @param {Number} endTime
+ * Ending time of animation.
+ * @return {Array}
+ * Array of path segment.
+ * e.g.[{x: {Number} time, y: {Number} progress}, ...]
+ */
+ createPathSegments(startTime, endTime) {
+ return createPathSegments(startTime, endTime,
+ this.minSegmentDuration, this.minProgressThreshold,
+ this.durationResolution, this.getSegment);
+ }
+
+ /**
+ * Return a coordinate as a graph segment at given time.
+ *
+ * @param {Number} time
+ * @return {Object}
+ * { x: Number, y: Number }
+ */
+ getSegment(time) {
+ const value = this.getValue(time);
+ return { x: time, y: value };
+ }
+}
+
+/**
+ * Create the path segments from given parameters.
+ *
+ * @param {Number} startTime
+ * Starting time of animation.
+ * @param {Number} endTime
+ * Ending time of animation.
+ * @param {Number} minSegmentDuration
+ * Minimum segment duration.
+ * @param {Number} minProgressThreshold
+ * Minimum progress threshold.
+ * @param {Number} resolution
+ * Duration resolution for first time.
+ * @param {Function} getSegment
+ * A function that calculate the graph segment.
+ * @return {Array}
+ * Array of path segment.
+ * e.g.[{x: {Number} time, y: {Number} progress}, ...]
+ */
+function createPathSegments(startTime, endTime, minSegmentDuration,
+ minProgressThreshold, resolution, getSegment) {
+ // If the duration is too short, early return.
+ if (endTime - startTime < minSegmentDuration) {
+ return [getSegment(startTime), getSegment(endTime)];
+ }
+
+ // Otherwise, start creating segments.
+ let pathSegments = [];
+
+ // Append the segment for the startTime position.
+ const startTimeSegment = getSegment(startTime);
+ pathSegments.push(startTimeSegment);
+ let previousSegment = startTimeSegment;
+
+ // Split the duration in equal intervals, and iterate over them.
+ // See the definition of DURATION_RESOLUTION for more information about this.
+ const interval = (endTime - startTime) / resolution;
+ for (let index = 1; index <= resolution; index++) {
+ // Create a segment for this interval.
+ const currentSegment = getSegment(startTime + index * interval);
+
+ // If the distance between the Y coordinate (the animation's progress) of
+ // the previous segment and the Y coordinate of the current segment is too
+ // large, then recurse with a smaller duration to get more details
+ // in the graph.
+ if (Math.abs(currentSegment.y - previousSegment.y) > minProgressThreshold) {
+ // Divide the current interval (excluding start and end bounds
+ // by adding/subtracting BOUND_EXCLUDING_TIME).
+ const nextStartTime = previousSegment.x + BOUND_EXCLUDING_TIME;
+ const nextEndTime = currentSegment.x - BOUND_EXCLUDING_TIME;
+ const segments =
+ createPathSegments(nextStartTime, nextEndTime, minSegmentDuration,
+ minProgressThreshold, DURATION_RESOLUTION, getSegment);
+ pathSegments = pathSegments.concat(segments);
+ }
+
+ pathSegments.push(currentSegment);
+ previousSegment = currentSegment;
+ }
+
+ return pathSegments;
+}
+
+/**
+ * Return preferred duration resolution.
+ * This corresponds to narrow interval keyframe offset.
+ *
+ * @param {Array} keyframes
+ * Array of keyframe.
+ * @return {Number}
+ * Preferred duration resolution.
+ */
+function getPreferredDurationResolution(keyframes) {
+ if (!keyframes) {
+ return DURATION_RESOLUTION;
+ }
+
+ let durationResolution = DURATION_RESOLUTION;
+ let previousOffset = 0;
+ for (let keyframe of keyframes) {
+ if (previousOffset && previousOffset != keyframe.offset) {
+ const interval = keyframe.offset - previousOffset;
+ durationResolution = Math.max(durationResolution, Math.ceil(1 / interval));
+ }
+ previousOffset = keyframe.offset;
+ }
+
+ return durationResolution;
+}
+
+/**
+ * Return preferred progress threshold to render summary graph.
+ *
+ * @param {Object} state
+ * State of animation.
+ * @param {Array} keyframes
+ * Array of keyframe.
+ * @return {float}
+ * Preferred threshold.
+ */
+function getPreferredProgressThreshold(state, keyframes) {
+ let threshold = DEFAULT_MIN_PROGRESS_THRESHOLD;
+ let stepsOrFrames;
+
+ if ((stepsOrFrames = getStepsOrFramesCount(state.easing))) {
+ threshold = Math.min(threshold, (1 / (stepsOrFrames + 1)));
+ }
+
+ if (!keyframes) {
+ return threshold;
+ }
+
+ for (let i = 0; i < keyframes.length - 1; i++) {
+ const keyframe = keyframes[i];
+
+ if (!keyframe.easing) {
+ continue;
+ }
+
+ if ((stepsOrFrames = getStepsOrFramesCount(keyframe.easing))) {
+ const nextKeyframe = keyframes[i + 1];
+ threshold =
+ Math.min(threshold,
+ 1 / (stepsOrFrames + 1) * (nextKeyframe.offset - keyframe.offset));
+ }
+ }
+
+ return threshold;
+}
+
+function getStepsOrFramesCount(easing) {
+ const stepsOrFramesFunction = easing.match(/(steps|frames)\((\d+)/);
+ return stepsOrFramesFunction ? parseInt(stepsOrFramesFunction[2], 10) : 0;
+}
+
+/**
+ * Return path string for 'd' attribute for <path> from given segments.
+ *
+ * @param {Array} segments
+ * e.g. [{ x: 100, y: 0 }, { x: 200, y: 1 }]
+ * @return {String}
+ * Path string.
+ * e.g. "L100,0 L200,1"
+ */
+function toPathString(segments) {
+ let pathString = "";
+ segments.forEach(segment => {
+ pathString += `L${ segment.x },${ segment.y } `;
+ });
+ return pathString;
+}
+
+exports.SummaryGraphHelper = SummaryGraphHelper;
+exports.toPathString = toPathString;
--- a/devtools/client/inspector/animation/utils/moz.build
+++ b/devtools/client/inspector/animation/utils/moz.build
@@ -1,9 +1,10 @@
# 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/.
DevToolsModules(
+ 'graph-helper.js',
'l10n.js',
'timescale.js',
'utils.js',
)
--- a/devtools/client/inspector/inspector.js
+++ b/devtools/client/inspector/inspector.js
@@ -802,17 +802,17 @@ Inspector.prototype = {
{
props: {
id: animationId,
title: animationTitle
},
panel: () => {
const AnimationInspector =
this.browserRequire("devtools/client/inspector/animation/animation");
- this.animationinspector = new AnimationInspector(this);
+ this.animationinspector = new AnimationInspector(this, this.panelWin);
return this.animationinspector.provider;
}
},
defaultTab == animationId);
} else {
this.sidebar.addFrameTab(
"animationinspector",
animationTitle,
--- a/devtools/client/themes/animation.css
+++ b/devtools/client/themes/animation.css
@@ -67,24 +67,35 @@
.animation-target .tag-name {
cursor: default;
}
/* Summary Graph */
.animation-summary-graph {
height: 100%;
+ padding-top: 5px;
width: calc(100% - var(--sidebar-width) - var(--graph-right-offset));
}
.animation-summary-graph-path {
height: 100%;
width: 100%;
}
+.animation-computed-timing-path path {
+ fill: lime;
+ vector-effect: non-scaling-stroke;
+ transform: scale(1, -1);
+}
+
+.animation-computed-timing-path path.infinity:nth-child(n+2) {
+ opacity: 0.3;
+}
+
/* No Animation Panel */
.animation-error-message {
overflow: auto;
}
.animation-error-message > p {
white-space: pre;
}