/* -*- 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)" } }); }