/* -*- 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, createSVGNode, TimeScale, getFormattedAnimationTitle} = require("devtools/client/inspector/animation-old/utils"); const {SummaryGraphHelper, getPreferredKeyframesProgressThreshold, getPreferredProgressThreshold} = require("devtools/client/inspector/animation-old/graph-helper"); const { LocalizationHelper } = require("devtools/shared/l10n"); const L10N = new LocalizationHelper("devtools/client/locales/animationinspector.properties"); // Show max 10 iterations for infinite animations // to give users a clue that the animation does repeat. const MAX_INFINITE_ANIMATIONS_ITERATIONS = 10; // Minimum opacity for semitransparent fill color for keyframes's easing graph. const MIN_KEYFRAMES_EASING_OPACITY = .5; /** * 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, tracks) { this.unrender(); this.animation = animation; // Animation summary graph element. const summaryEl = createSVGNode({ parent: this.containerEl, nodeType: "svg", attributes: { "class": "summary", "preserveAspectRatio": "none" } }); this.updateSummaryGraphViewBox(summaryEl); const {state} = this.animation; // Total displayed duration const totalDisplayedDuration = this.getTotalDisplayedDuration(); // Minimum segment duration is the duration of one pixel. const minSegmentDuration = totalDisplayedDuration / this.containerEl.clientWidth; // Minimum progress threshold for effect timing. const minEffectProgressThreshold = getPreferredProgressThreshold(state.easing); // Render summary graph. // The summary graph is constructed from keyframes's easing and effect timing. const graphHelper = new SummaryGraphHelper(this.win, state, minSegmentDuration); renderKeyframesEasingGraph(summaryEl, state, totalDisplayedDuration, minEffectProgressThreshold, tracks, graphHelper); if (state.easing !== "linear") { renderEffectEasingGraph(summaryEl, state, totalDisplayedDuration, minEffectProgressThreshold, graphHelper); } graphHelper.destroy(); // The animation name is displayed over the animation. const nameEl = createNode({ parent: this.containerEl, attributes: { "class": "name", "title": this.getTooltipText(state) } }); createSVGNode({ parent: createSVGNode({ parent: nameEl, nodeType: "svg", }), nodeType: "text", attributes: { "y": "50%", "x": "100%", }, textContent: state.name }); // Delay. if (state.delay) { // Negative delays need to start at 0. const delayEl = createNode({ parent: this.containerEl, attributes: { "class": "delay" + (state.delay < 0 ? " negative" : " positive") + (state.fill === "both" || state.fill === "backwards" ? " fill" : "") } }); this.updateDelayBounds(delayEl); } // endDelay if (state.iterationCount && state.endDelay) { const endDelayEl = createNode({ parent: this.containerEl, attributes: { "class": "end-delay" + (state.endDelay < 0 ? " negative" : " positive") + (state.fill === "both" || state.fill === "forwards" ? " fill" : "") } }); this.updateEndDelayBounds(endDelayEl); } }, /** * Update animation and updating its DOM accordingly. * Unlike 'render' method, this method does not generate any elements, but update * the bounds of DOM. * @param {Object} animation */ update: function(animation) { this.animation = animation; this.updateSummaryGraphViewBox(this.containerEl.querySelector(".summary")); const delayEl = this.containerEl.querySelector(".delay"); if (delayEl) { this.updateDelayBounds(delayEl); } const endDelayEl = this.containerEl.querySelector(".end-delay"); if (endDelayEl) { this.updateEndDelayBounds(endDelayEl); } }, /** * Update viewBox and style of SVG element for summary graph to fit to latest * TimeScale. * @param {Element} summaryEl - SVG element for summary graph. */ updateSummaryGraphViewBox: function(summaryEl) { const {x, delayW} = TimeScale.getAnimationDimensions(this.animation); const totalDisplayedDuration = this.getTotalDisplayedDuration(); const strokeHeightForViewBox = 0.5 / this.containerEl.clientHeight; const {state} = this.animation; summaryEl.setAttribute("viewBox", `${state.delay < 0 ? state.delay : 0} ` + `-${1 + strokeHeightForViewBox} ` + `${totalDisplayedDuration} ` + `${1 + strokeHeightForViewBox * 2}`); summaryEl.setAttribute("style", `left: ${ x - (state.delay > 0 ? delayW : 0) }%`); }, /** * Update bounds of element which represents delay to fit to latest TimeScale. * @param {Element} delayEl - which represents delay. */ updateDelayBounds: function(delayEl) { const {delayX, delayW} = TimeScale.getAnimationDimensions(this.animation); delayEl.style.left = `${ delayX }%`; delayEl.style.width = `${ delayW }%`; }, /** * Update bounds of element which represents endDelay to fit to latest TimeScale. * @param {Element} endDelayEl - which represents endDelay. */ updateEndDelayBounds: function(endDelayEl) { const {endDelayX, endDelayW} = TimeScale.getAnimationDimensions(this.animation); endDelayEl.style.left = `${ endDelayX }%`; endDelayEl.style.width = `${ endDelayW }%`; }, getTotalDisplayedDuration: function() { return this.animation.state.playbackRate * TimeScale.getDuration(); }, getTooltipText: function(state) { const 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) { const iterationStartTime = state.iterationStart * state.duration / 1000; text += L10N.getFormatStr("player.animationIterationStartLabel", state.iterationStart, L10N.numberWithDecimals(iterationStartTime, 2)); text += "\n"; } // Adding the easing if it is not "linear". if (state.easing && state.easing !== "linear") { text += L10N.getStr("player.animationOverallEasingLabel") + " "; 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 it is not "normal". if (state.direction && state.direction !== "normal") { 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 the animation-timing-function // if it is not "ease" which is default value for CSS Animations. if (state.animationTimingFunction && state.animationTimingFunction !== "ease") { text += L10N.getStr("player.animationTimingFunctionLabel") + " "; text += state.animationTimingFunction; 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; } }; /** * Render keyframes's easing graph. * @param {Element} parentEl - Parent element of this appended path element. * @param {Object} state - State of animation. * @param {float} totalDisplayedDuration - Displayable total duration. * @param {float} minEffectProgressThreshold - Minimum progress threshold for effect. * @param {Object} tracks - The value of AnimationsTimeline.getTracks(). * @param {Object} graphHelper - SummaryGraphHelper. */ function renderKeyframesEasingGraph(parentEl, state, totalDisplayedDuration, minEffectProgressThreshold, tracks, graphHelper) { const keyframesList = getOffsetAndEasingOnlyKeyframesList(tracks); const keyframeEasingOpacity = Math.max(1 / keyframesList.length, MIN_KEYFRAMES_EASING_OPACITY); for (const keyframes of keyframesList) { const minProgressTreshold = Math.min(minEffectProgressThreshold, getPreferredKeyframesProgressThreshold(keyframes)); graphHelper.setMinProgressThreshold(minProgressTreshold); graphHelper.setKeyframes(keyframes); graphHelper.setClosePathNeeded(true); const element = renderGraph(parentEl, state, totalDisplayedDuration, "keyframes-easing", graphHelper); element.style.opacity = keyframeEasingOpacity; } } /** * Render effect easing graph. * @param {Element} parentEl - Parent element of this appended path element. * @param {Object} state - State of animation. * @param {float} totalDisplayedDuration - Displayable total duration. * @param {float} minEffectProgressThreshold - Minimum progress threshold for effect. * @param {Object} graphHelper - SummaryGraphHelper. */ function renderEffectEasingGraph(parentEl, state, totalDisplayedDuration, minEffectProgressThreshold, graphHelper) { graphHelper.setMinProgressThreshold(minEffectProgressThreshold); graphHelper.setKeyframes(null); graphHelper.setClosePathNeeded(false); renderGraph(parentEl, state, totalDisplayedDuration, "effect-easing", graphHelper); } /** * Render a graph of given parameters. * @param {Element} parentEl - Parent element of this appended path element. * @param {Object} state - State of animation. * @param {float} totalDisplayedDuration - Displayable total duration. * @param {String} className - Class name for graph element. * @param {Object} graphHelper - SummaryGraphHelper. */ function renderGraph(parentEl, state, totalDisplayedDuration, className, graphHelper) { const graphEl = createSVGNode({ parent: parentEl, nodeType: "g", attributes: { "class": className, } }); // Starting time of main iteration. let mainIterationStartTime = 0; let iterationStart = state.iterationStart; let iterationCount = state.iterationCount ? state.iterationCount : Infinity; graphHelper.setFillMode(state.fill); graphHelper.setOriginalBehavior(true); // Append delay. if (state.delay > 0) { renderDelay(graphEl, state, graphHelper); 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(graphEl, state, mainIterationStartTime, firstSectionCount, graphHelper); } if (iterationCount === Infinity) { // If the animation repeats infinitely, // we fill the remaining area with iteration paths. renderInfinity(graphEl, state, mainIterationStartTime, firstSectionCount, totalDisplayedDuration, graphHelper); } else { // Otherwise, we show remaining iterations, endDelay and fill. // Append forwards fill-mode. if (state.fill === "both" || state.fill === "forwards") { renderForwardsFill(graphEl, state, mainIterationStartTime, iterationCount, totalDisplayedDuration, graphHelper); } // Append middle section of iterations. // e.g. // if { iterationStart: 0.25, iterations: 3 }, middleSectionCount is 2. const middleSectionCount = Math.floor(iterationCount - firstSectionCount); renderMiddleIterations(graphEl, state, mainIterationStartTime, firstSectionCount, middleSectionCount, graphHelper); // 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(graphEl, state, mainIterationStartTime, firstSectionCount, middleSectionCount, lastSectionCount, graphHelper); } // Append endDelay. if (state.endDelay > 0) { renderEndDelay(graphEl, state, mainIterationStartTime, iterationCount, graphHelper); } } // Append negative delay (which overlap the animation). if (state.delay < 0) { graphHelper.setFillMode("both"); graphHelper.setOriginalBehavior(false); renderNegativeDelayHiddenProgress(graphEl, state, graphHelper); } // Append negative endDelay (which overlap the animation). if (state.iterationCount && state.endDelay < 0) { graphHelper.setFillMode("both"); graphHelper.setOriginalBehavior(false); renderNegativeEndDelayHiddenProgress(graphEl, state, graphHelper); } return graphEl; } /** * Render delay section. * @param {Element} parentEl - Parent element of this appended path element. * @param {Object} state - State of animation. * @param {Object} graphHelper - SummaryGraphHelper. */ function renderDelay(parentEl, state, graphHelper) { const startSegment = graphHelper.getSegment(0); const endSegment = { x: state.delay, y: startSegment.y }; graphHelper.appendShapePath(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 {Object} graphHelper - SummaryGraphHelper. */ function renderFirstIteration(parentEl, state, mainIterationStartTime, firstSectionCount, graphHelper) { const startTime = mainIterationStartTime; const endTime = startTime + firstSectionCount * state.duration; const segments = graphHelper.createPathSegments(startTime, endTime); graphHelper.appendShapePath(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 {Object} graphHelper - SummaryGraphHelper. */ function renderMiddleIterations(parentEl, state, mainIterationStartTime, firstSectionCount, middleSectionCount, graphHelper) { 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 = graphHelper.createPathSegments(startTime, endTime); graphHelper.appendShapePath(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 {Object} graphHelper - SummaryGraphHelper. */ function renderLastIteration(parentEl, state, mainIterationStartTime, firstSectionCount, middleSectionCount, lastSectionCount, graphHelper) { const startTime = mainIterationStartTime + (firstSectionCount + middleSectionCount) * state.duration; const endTime = startTime + lastSectionCount * state.duration; const segments = graphHelper.createPathSegments(startTime, endTime); graphHelper.appendShapePath(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 {Object} graphHelper - SummaryGraphHelper. */ function renderInfinity(parentEl, state, mainIterationStartTime, firstSectionCount, totalDuration, graphHelper) { // 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 = graphHelper.createPathSegments(firstStartTime, firstEndTime); graphHelper.appendShapePath(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 }; }); } graphHelper.appendShapePath(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} graphHelper - SummaryGraphHelper. */ function renderEndDelay(parentEl, state, mainIterationStartTime, iterationCount, graphHelper) { const startTime = mainIterationStartTime + iterationCount * state.duration; const startSegment = graphHelper.getSegment(startTime); const endSegment = { x: startTime + state.endDelay, y: startSegment.y }; graphHelper.appendShapePath(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} graphHelper - SummaryGraphHelper. */ function renderForwardsFill(parentEl, state, mainIterationStartTime, iterationCount, totalDuration, graphHelper) { const startTime = mainIterationStartTime + iterationCount * state.duration + (state.endDelay > 0 ? state.endDelay : 0); const startSegment = graphHelper.getSegment(startTime); const endSegment = { x: totalDuration, y: startSegment.y }; graphHelper.appendShapePath(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 {Object} graphHelper - SummaryGraphHelper. */ function renderNegativeDelayHiddenProgress(parentEl, state, graphHelper) { const startTime = state.delay; const endTime = 0; const segments = graphHelper.createPathSegments(startTime, endTime); graphHelper.appendShapePath(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 {Object} graphHelper - SummaryGraphHelper. */ function renderNegativeEndDelayHiddenProgress(parentEl, state, graphHelper) { const endTime = state.delay + state.iterationCount * state.duration; const startTime = endTime + state.endDelay; const segments = graphHelper.createPathSegments(startTime, endTime); graphHelper.appendShapePath(parentEl, segments, "enddelay-path negative"); } /** * Create new keyframes object which has only offset and easing. * Also, the returned value has no duplication. * @param {Object} tracks - The value of AnimationsTimeline.getTracks(). * @return {Array} keyframes list. */ function getOffsetAndEasingOnlyKeyframesList(tracks) { return Object.keys(tracks).reduce((result, name) => { const track = tracks[name]; const exists = result.find(keyframes => { if (track.length !== keyframes.length) { return false; } for (let i = 0; i < track.length; i++) { const keyframe1 = track[i]; const keyframe2 = keyframes[i]; if (keyframe1.offset !== keyframe2.offset || keyframe1.easing !== keyframe2.easing) { return false; } } return true; }); if (!exists) { const keyframes = track.map(keyframe => { return { offset: keyframe.offset, easing: keyframe.easing }; }); result.push(keyframes); } return result; }, []); }