Dactyloidae/devtools/client/animationinspector/components/animation-time-block.js

719 lines
28 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, TimeScale} = require("devtools/client/animationinspector/utils");
const { LocalizationHelper } = require("devtools/shared/l10n");
const L10N =
new LocalizationHelper("devtools/client/locales/animationinspector.properties");
// 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
// MIN_PROGRESS_THRESHOLD, then createPathSegments re-divides
// by DURATION_RESOLUTION.
// DURATION_RESOLUTION shoud be integer and more than 2.
const DURATION_RESOLUTION = 4;
// MIN_PROGRESS_THRESHOLD shoud be between more than 0 to 1.
const MIN_PROGRESS_THRESHOLD = 0.1;
// Show max 10 iterations for infinite animations
// to give users a clue that the animation does repeat.
const MAX_INFINITE_ANIMATIONS_ITERATIONS = 10;
// SVG namespace
const SVG_NS = "http://www.w3.org/2000/svg";
/**
* UI component responsible for displaying a single animation timeline, which
* basically looks like a rectangle that shows the delay and iterations.
*/
function AnimationTimeBlock() {
EventEmitter.decorate(this);
this.onClick = this.onClick.bind(this);
}
exports.AnimationTimeBlock = AnimationTimeBlock;
AnimationTimeBlock.prototype = {
init: function (containerEl) {
this.containerEl = containerEl;
this.containerEl.addEventListener("click", this.onClick);
},
destroy: function () {
this.containerEl.removeEventListener("click", this.onClick);
this.unrender();
this.containerEl = null;
this.animation = null;
},
unrender: function () {
while (this.containerEl.firstChild) {
this.containerEl.firstChild.remove();
}
},
render: function (animation) {
this.unrender();
this.animation = animation;
let {state} = this.animation;
// Create a container element to hold the delay and iterations.
// It is positioned according to its delay (divided by the playbackrate),
// and its width is according to its duration (divided by the playbackrate).
const {x, delayX, delayW, endDelayX, endDelayW} =
TimeScale.getAnimationDimensions(animation);
// Animation summary graph element.
const summaryEl = createNode({
parent: this.containerEl,
namespace: "http://www.w3.org/2000/svg",
nodeType: "svg",
attributes: {
"class": "summary",
"preserveAspectRatio": "none",
"style": `left: ${ x - (state.delay > 0 ? delayW : 0) }%`
}
});
// Total displayed duration
const totalDisplayedDuration = state.playbackRate * TimeScale.getDuration();
// Calculate stroke height in viewBox to display stroke of path.
const strokeHeightForViewBox = 0.5 / this.containerEl.clientHeight;
// Set viewBox
summaryEl.setAttribute("viewBox",
`${ state.delay < 0 ? state.delay : 0 }
-${ 1 + strokeHeightForViewBox }
${ totalDisplayedDuration }
${ 1 + strokeHeightForViewBox * 2 }`);
// Get a helper function that returns the path segment of timing-function.
const segmentHelper = getSegmentHelper(state, this.win);
// Minimum segment duration is the duration of one pixel.
const minSegmentDuration =
totalDisplayedDuration / this.containerEl.clientWidth;
// Minimum progress threshold.
let minProgressThreshold = MIN_PROGRESS_THRESHOLD;
// If the easing is step function,
// minProgressThreshold should be changed by the steps.
const stepFunction = state.easing.match(/steps\((\d+)/);
if (stepFunction) {
minProgressThreshold = 1 / (parseInt(stepFunction[1], 10) + 1);
}
// Starting time of main iteration.
let mainIterationStartTime = 0;
let iterationStart = state.iterationStart;
let iterationCount = state.iterationCount ? state.iterationCount : Infinity;
// Append delay.
if (state.delay > 0) {
renderDelay(summaryEl, state, segmentHelper);
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) {
renderFirstIteration(summaryEl, state, mainIterationStartTime,
firstSectionCount, minSegmentDuration,
minProgressThreshold, segmentHelper);
}
if (iterationCount === Infinity) {
// If the animation repeats infinitely,
// we fill the remaining area with iteration paths.
renderInfinity(summaryEl, state, mainIterationStartTime,
firstSectionCount, totalDisplayedDuration,
minSegmentDuration, minProgressThreshold, segmentHelper);
} else {
// Otherwise, we show remaining iterations, endDelay and fill.
// Append forwards fill-mode.
if (state.fill === "both" || state.fill === "forwards") {
renderForwardsFill(summaryEl, state, mainIterationStartTime,
iterationCount, totalDisplayedDuration,
segmentHelper);
}
// Append middle section of iterations.
// e.g.
// if { iterationStart: 0.25, iterations: 3 }, middleSectionCount is 2.
const middleSectionCount =
Math.floor(iterationCount - firstSectionCount);
renderMiddleIterations(summaryEl, state, mainIterationStartTime,
firstSectionCount, middleSectionCount,
minSegmentDuration, minProgressThreshold,
segmentHelper);
// 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) {
renderLastIteration(summaryEl, state, mainIterationStartTime,
firstSectionCount, middleSectionCount,
lastSectionCount, minSegmentDuration,
minProgressThreshold, segmentHelper);
}
// Append endDelay.
if (state.endDelay > 0) {
renderEndDelay(summaryEl, state,
mainIterationStartTime, iterationCount, segmentHelper);
}
}
// Append negative delay (which overlap the animation).
if (state.delay < 0) {
segmentHelper.animation.effect.timing.fill = "both";
segmentHelper.asOriginalBehavior = false;
renderNegativeDelayHiddenProgress(summaryEl, state, minSegmentDuration,
minProgressThreshold, segmentHelper);
}
// Append negative endDelay (which overlap the animation).
if (state.iterationCount && state.endDelay < 0) {
if (segmentHelper.asOriginalBehavior) {
segmentHelper.animation.effect.timing.fill = "both";
segmentHelper.asOriginalBehavior = false;
}
renderNegativeEndDelayHiddenProgress(summaryEl, state,
minSegmentDuration,
minProgressThreshold,
segmentHelper);
}
// The animation name is displayed over the animation.
createNode({
parent: createNode({
parent: this.containerEl,
attributes: {
"class": "name",
"title": this.getTooltipText(state)
},
}),
textContent: state.name
});
// Delay.
if (state.delay) {
// Negative delays need to start at 0.
createNode({
parent: this.containerEl,
attributes: {
"class": "delay"
+ (state.delay < 0 ? " negative" : " positive")
+ (state.fill === "both" ||
state.fill === "backwards" ? " fill" : ""),
"style": `left:${ delayX }%; width:${ delayW }%;`
}
});
}
// endDelay
if (state.iterationCount && state.endDelay) {
createNode({
parent: this.containerEl,
attributes: {
"class": "end-delay"
+ (state.endDelay < 0 ? " negative" : " positive")
+ (state.fill === "both" ||
state.fill === "forwards" ? " fill" : ""),
"style": `left:${ endDelayX }%; width:${ endDelayW }%;`
}
});
}
},
getTooltipText: function (state) {
let getTime = time => L10N.getFormatStr("player.timeLabel",
L10N.numberWithDecimals(time / 1000, 2));
let text = "";
// Adding the name.
text += getFormattedAnimationTitle({state});
text += "\n";
// Adding the delay.
if (state.delay) {
text += L10N.getStr("player.animationDelayLabel") + " ";
text += getTime(state.delay);
text += "\n";
}
// Adding the duration.
text += L10N.getStr("player.animationDurationLabel") + " ";
text += getTime(state.duration);
text += "\n";
// Adding the endDelay.
if (state.endDelay) {
text += L10N.getStr("player.animationEndDelayLabel") + " ";
text += getTime(state.endDelay);
text += "\n";
}
// Adding the iteration count (the infinite symbol, or an integer).
if (state.iterationCount !== 1) {
text += L10N.getStr("player.animationIterationCountLabel") + " ";
text += state.iterationCount ||
L10N.getStr("player.infiniteIterationCountText");
text += "\n";
}
// Adding the iteration start.
if (state.iterationStart !== 0) {
let iterationStartTime = state.iterationStart * state.duration / 1000;
text += L10N.getFormatStr("player.animationIterationStartLabel",
state.iterationStart,
L10N.numberWithDecimals(iterationStartTime, 2));
text += "\n";
}
// Adding the easing.
if (state.easing) {
text += L10N.getStr("player.animationEasingLabel") + " ";
text += state.easing;
text += "\n";
}
// Adding the fill mode.
if (state.fill) {
text += L10N.getStr("player.animationFillLabel") + " ";
text += state.fill;
text += "\n";
}
// Adding the direction mode.
if (state.direction) {
text += L10N.getStr("player.animationDirectionLabel") + " ";
text += state.direction;
text += "\n";
}
// Adding the playback rate if it's different than 1.
if (state.playbackRate !== 1) {
text += L10N.getStr("player.animationRateLabel") + " ";
text += state.playbackRate;
text += "\n";
}
// Adding a note that the animation is running on the compositor thread if
// needed.
if (state.propertyState) {
if (state.propertyState
.every(propState => propState.runningOnCompositor)) {
text += L10N.getStr("player.allPropertiesOnCompositorTooltip");
} else if (state.propertyState
.some(propState => propState.runningOnCompositor)) {
text += L10N.getStr("player.somePropertiesOnCompositorTooltip");
}
} else if (state.isRunningOnCompositor) {
text += L10N.getStr("player.runningOnCompositorTooltip");
}
return text;
},
onClick: function (e) {
e.stopPropagation();
this.emit("selected", this.animation);
},
get win() {
return this.containerEl.ownerDocument.defaultView;
}
};
/**
* Get a formatted title for this animation. This will be either:
* "some-name", "some-name : CSS Transition", "some-name : CSS Animation",
* "some-name : Script Animation", or "Script Animation", depending
* if the server provides the type, what type it is and if the animation
* has a name
* @param {AnimationPlayerFront} animation
*/
function getFormattedAnimationTitle({state}) {
// Older servers don't send a type, and only know about
// CSSAnimations and CSSTransitions, so it's safe to use
// just the name.
if (!state.type) {
return state.name;
}
// Script-generated animations may not have a name.
if (state.type === "scriptanimation" && !state.name) {
return L10N.getStr("timeline.scriptanimation.unnamedLabel");
}
return L10N.getFormatStr(`timeline.${state.type}.nameLabel`, state.name);
}
/**
* Render delay section.
* @param {Element} parentEl - Parent element of this appended path element.
* @param {Object} state - State of animation.
* @param {Object} segmentHelper - The object returned by getSegmentHelper.
*/
function renderDelay(parentEl, state, segmentHelper) {
const startSegment = segmentHelper.getSegment(0);
const endSegment = { x: state.delay, y: startSegment.y };
appendPathElement(parentEl, [startSegment, endSegment], "delay-path");
}
/**
* Render first iteration section.
* @param {Element} parentEl - Parent element of this appended path element.
* @param {Object} state - State of animation.
* @param {Number} mainIterationStartTime - Starting time of main iteration.
* @param {Number} firstSectionCount - Iteration count of first section.
* @param {Number} minSegmentDuration - Minimum segment duration.
* @param {Number} minProgressThreshold - Minimum progress threshold.
* @param {Object} segmentHelper - The object returned by getSegmentHelper.
*/
function renderFirstIteration(parentEl, state, mainIterationStartTime,
firstSectionCount, minSegmentDuration,
minProgressThreshold, segmentHelper) {
const startTime = mainIterationStartTime;
const endTime = startTime + firstSectionCount * state.duration;
const segments =
createPathSegments(startTime, endTime, minSegmentDuration,
minProgressThreshold, segmentHelper);
appendPathElement(parentEl, segments, "iteration-path");
}
/**
* Render middle iterations section.
* @param {Element} parentEl - Parent element of this appended path element.
* @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} minSegmentDuration - Minimum segment duration.
* @param {Number} minProgressThreshold - Minimum progress threshold.
* @param {Object} segmentHelper - The object returned by getSegmentHelper.
*/
function renderMiddleIterations(parentEl, state, mainIterationStartTime,
firstSectionCount, middleSectionCount,
minSegmentDuration, minProgressThreshold,
segmentHelper) {
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 =
createPathSegments(startTime, endTime, minSegmentDuration,
minProgressThreshold, segmentHelper);
appendPathElement(parentEl, segments, "iteration-path");
}
}
/**
* Render last iteration section.
* @param {Element} parentEl - Parent element of this appended path element.
* @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 {Number} minSegmentDuration - Minimum segment duration.
* @param {Number} minProgressThreshold - Minimum progress threshold.
* @param {Object} segmentHelper - The object returned by getSegmentHelper.
*/
function renderLastIteration(parentEl, state, mainIterationStartTime,
firstSectionCount, middleSectionCount,
lastSectionCount, minSegmentDuration,
minProgressThreshold, segmentHelper) {
const startTime = mainIterationStartTime +
(firstSectionCount + middleSectionCount) * state.duration;
const endTime = startTime + lastSectionCount * state.duration;
const segments =
createPathSegments(startTime, endTime, minSegmentDuration,
minProgressThreshold, segmentHelper);
appendPathElement(parentEl, segments, "iteration-path");
}
/**
* Render Infinity iterations.
* @param {Element} parentEl - Parent element of this appended path element.
* @param {Object} state - State of animation.
* @param {Number} mainIterationStartTime - Starting time of main iteration.
* @param {Number} firstSectionCount - Iteration count of first section.
* @param {Number} totalDuration - Displayed max duration.
* @param {Number} minSegmentDuration - Minimum segment duration.
* @param {Number} minProgressThreshold - Minimum progress threshold.
* @param {Object} segmentHelper - The object returned by getSegmentHelper.
*/
function renderInfinity(parentEl, state, mainIterationStartTime,
firstSectionCount, totalDuration, minSegmentDuration,
minProgressThreshold, segmentHelper) {
// Calculate the number of iterations to display,
// with a maximum of MAX_INFINITE_ANIMATIONS_ITERATIONS
let uncappedInfinityIterationCount =
(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 =
createPathSegments(firstStartTime, firstEndTime, minSegmentDuration,
minProgressThreshold, segmentHelper);
appendPathElement(parentEl, firstSegments, "iteration-path infinity");
// 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 };
});
}
appendPathElement(parentEl, segments, "iteration-path infinity copied");
}
}
/**
* Render endDelay section.
* @param {Element} parentEl - Parent element of this appended path element.
* @param {Object} state - State of animation.
* @param {Number} mainIterationStartTime - Starting time of main iteration.
* @param {Number} iterationCount - Whole iteration count.
* @param {Object} segmentHelper - The object returned by getSegmentHelper.
*/
function renderEndDelay(parentEl, state,
mainIterationStartTime, iterationCount, segmentHelper) {
const startTime = mainIterationStartTime + iterationCount * state.duration;
const startSegment = segmentHelper.getSegment(startTime);
const endSegment = { x: startTime + state.endDelay, y: startSegment.y };
appendPathElement(parentEl, [startSegment, endSegment], "enddelay-path");
}
/**
* Render forwards fill section.
* @param {Element} parentEl - Parent element of this appended path element.
* @param {Object} state - State of animation.
* @param {Number} mainIterationStartTime - Starting time of main iteration.
* @param {Number} iterationCount - Whole iteration count.
* @param {Number} totalDuration - Displayed max duration.
* @param {Object} segmentHelper - The object returned by getSegmentHelper.
*/
function renderForwardsFill(parentEl, state, mainIterationStartTime,
iterationCount, totalDuration, segmentHelper) {
const startTime = mainIterationStartTime + iterationCount * state.duration +
(state.endDelay > 0 ? state.endDelay : 0);
const startSegment = segmentHelper.getSegment(startTime);
const endSegment = { x: totalDuration, y: startSegment.y };
appendPathElement(parentEl, [startSegment, endSegment], "fill-forwards-path");
}
/**
* Render hidden progress of negative delay.
* @param {Element} parentEl - Parent element of this appended path element.
* @param {Object} state - State of animation.
* @param {Number} minSegmentDuration - Minimum segment duration.
* @param {Number} minProgressThreshold - Minimum progress threshold.
* @param {Object} segmentHelper - The object returned by getSegmentHelper.
*/
function renderNegativeDelayHiddenProgress(parentEl, state, minSegmentDuration,
minProgressThreshold,
segmentHelper) {
const startTime = state.delay;
const endTime = 0;
const segments =
createPathSegments(startTime, endTime, minSegmentDuration,
minProgressThreshold, segmentHelper);
appendPathElement(parentEl, segments, "delay-path negative");
}
/**
* Render hidden progress of negative endDelay.
* @param {Element} parentEl - Parent element of this appended path element.
* @param {Object} state - State of animation.
* @param {Number} minSegmentDuration - Minimum segment duration.
* @param {Number} minProgressThreshold - Minimum progress threshold.
* @param {Object} segmentHelper - The object returned by getSegmentHelper.
*/
function renderNegativeEndDelayHiddenProgress(parentEl, state,
minSegmentDuration,
minProgressThreshold,
segmentHelper) {
const endTime = state.delay + state.iterationCount * state.duration;
const startTime = endTime + state.endDelay;
const segments =
createPathSegments(startTime, endTime, minSegmentDuration,
minProgressThreshold, segmentHelper);
appendPathElement(parentEl, segments, "enddelay-path negative");
}
/**
* Get a helper function which returns the segment coord from given time.
* @param {Object} state - animation state
* @param {Object} win - window object
* @return {Object} A segmentHelper object that has the following properties:
* - animation: The script animation used to get the progress
* - endTime: The end time of the animation
* - asOriginalBehavior: The spec is that the progress of animation is changed
* if the time of setCurrentTime is during the endDelay.
* Likewise, in case the time is less than 0.
* If this flag is true, we prevent the time
* to make the same animation behavior as the original.
* - getSegment: Helper function that, given a time,
* will calculate the progress through the dummy animation.
*/
function getSegmentHelper(state, win) {
// Create a dummy Animation timing data as the
// state object we're being passed in.
const timing = Object.assign({}, state, {
iterations: state.iterationCount ? state.iterationCount : Infinity
});
// Create a dummy Animation with the given timing.
const dummyAnimation =
new win.Animation(new win.KeyframeEffect(null, null, timing), null);
// Returns segment helper object.
return {
animation: dummyAnimation,
endTime: dummyAnimation.effect.getComputedTiming().endTime,
asOriginalBehavior: true,
getSegment: function (time) {
if (this.asOriginalBehavior) {
// If the given time is less than 0, returned progress is 0.
if (time < 0) {
return { x: time, y: 0 };
}
// Avoid to apply over endTime.
this.animation.currentTime = time < this.endTime ? time : this.endTime;
} else {
this.animation.currentTime = time;
}
const progress = this.animation.effect.getComputedTiming().progress;
return { x: time, y: Math.max(progress, 0) };
}
};
}
/**
* 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 {Object} segmentHelper - The object of getSegmentHelper.
* @return {Array} path segments -
* [{x: {Number} time, y: {Number} progress}, ...]
*/
function createPathSegments(startTime, endTime, minSegmentDuration,
minProgressThreshold, segmentHelper) {
// If the duration is too short, early return.
if (endTime - startTime < minSegmentDuration) {
return [segmentHelper.getSegment(startTime),
segmentHelper.getSegment(endTime)];
}
// Otherwise, start creating segments.
let pathSegments = [];
// Append the segment for the startTime position.
const startTimeSegment = segmentHelper.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) / DURATION_RESOLUTION;
for (let index = 1; index <= DURATION_RESOLUTION; index++) {
// Create a segment for this interval.
const currentSegment =
segmentHelper.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 1ms).
pathSegments = pathSegments.concat(
createPathSegments(previousSegment.x + 1, currentSegment.x - 1,
minSegmentDuration, minProgressThreshold,
segmentHelper));
}
pathSegments.push(currentSegment);
previousSegment = currentSegment;
}
return pathSegments;
}
/**
* Append path element.
* @param {Element} parentEl - Parent element of this appended path element.
* @param {Array} pathSegments - Path segments. Please see createPathSegments.
* @param {String} cls - Class name.
* @return {Element} path element.
*/
function appendPathElement(parentEl, pathSegments, cls) {
// Create path string.
let path = `M${ pathSegments[0].x },0`;
pathSegments.forEach(pathSegment => {
path += ` L${ pathSegment.x },${ pathSegment.y }`;
});
path += ` L${ pathSegments[pathSegments.length - 1].x },0 Z`;
// Append and return the path element.
return createNode({
parent: parentEl,
namespace: SVG_NS,
nodeType: "path",
attributes: {
"d": path,
"class": cls,
"vector-effect": "non-scaling-stroke",
"transform": "scale(1, -1)"
}
});
}