diff options
Diffstat (limited to 'devtools/client/animationinspector/components')
7 files changed, 1721 insertions, 0 deletions
diff --git a/devtools/client/animationinspector/components/animation-details.js b/devtools/client/animationinspector/components/animation-details.js new file mode 100644 index 000000000..c042ccac0 --- /dev/null +++ b/devtools/client/animationinspector/components/animation-details.js @@ -0,0 +1,222 @@ +/* -*- 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 {Task} = require("devtools/shared/task"); +const EventEmitter = require("devtools/shared/event-emitter"); +const {createNode, TimeScale} = require("devtools/client/animationinspector/utils"); +const {Keyframes} = require("devtools/client/animationinspector/components/keyframes"); + +/** + * UI component responsible for displaying detailed information for a given + * animation. + * This includes information about timing, easing, keyframes, animated + * properties. + * + * @param {Object} serverTraits The list of server-side capabilities. + */ +function AnimationDetails(serverTraits) { + EventEmitter.decorate(this); + + this.onFrameSelected = this.onFrameSelected.bind(this); + + this.keyframeComponents = []; + this.serverTraits = serverTraits; +} + +exports.AnimationDetails = AnimationDetails; + +AnimationDetails.prototype = { + // These are part of frame objects but are not animated properties. This + // array is used to skip them. + NON_PROPERTIES: ["easing", "composite", "computedOffset", "offset"], + + init: function (containerEl) { + this.containerEl = containerEl; + }, + + destroy: function () { + this.unrender(); + this.containerEl = null; + this.serverTraits = null; + }, + + unrender: function () { + for (let component of this.keyframeComponents) { + component.off("frame-selected", this.onFrameSelected); + component.destroy(); + } + this.keyframeComponents = []; + + while (this.containerEl.firstChild) { + this.containerEl.firstChild.remove(); + } + }, + + getPerfDataForProperty: function (animation, propertyName) { + let warning = ""; + let className = ""; + if (animation.state.propertyState) { + let isRunningOnCompositor; + for (let propState of animation.state.propertyState) { + if (propState.property == propertyName) { + isRunningOnCompositor = propState.runningOnCompositor; + if (typeof propState.warning != "undefined") { + warning = propState.warning; + } + break; + } + } + if (isRunningOnCompositor && warning == "") { + className = "oncompositor"; + } else if (!isRunningOnCompositor && warning != "") { + className = "warning"; + } + } + return {className, warning}; + }, + + /** + * Get a list of the tracks of the animation actor + * @return {Object} A list of tracks, one per animated property, each + * with a list of keyframes + */ + getTracks: Task.async(function* () { + let tracks = {}; + + /* + * getFrames is a AnimationPlayorActor method that returns data about the + * keyframes of the animation. + * In FF48, the data it returns change, and will hold only longhand + * properties ( e.g. borderLeftWidth ), which does not match what we + * want to display in the animation detail. + * A new AnimationPlayerActor function, getProperties, is introduced, + * that returns the animated css properties of the animation and their + * keyframes values. + * If the animation actor has the getProperties function, we use it, and if + * not, we fall back to getFrames, which then returns values we used to + * handle. + */ + if (this.serverTraits.hasGetProperties) { + let properties = yield this.animation.getProperties(); + for (let {name, values} of properties) { + if (!tracks[name]) { + tracks[name] = []; + } + + for (let {value, offset} of values) { + tracks[name].push({value, offset}); + } + } + } else { + let frames = yield this.animation.getFrames(); + for (let frame of frames) { + for (let name in frame) { + if (this.NON_PROPERTIES.indexOf(name) != -1) { + continue; + } + + if (!tracks[name]) { + tracks[name] = []; + } + + tracks[name].push({ + value: frame[name], + offset: frame.computedOffset + }); + } + } + } + + return tracks; + }), + + render: Task.async(function* (animation) { + this.unrender(); + + if (!animation) { + return; + } + this.animation = animation; + + // We might have been destroyed in the meantime, or the component might + // have been re-rendered. + if (!this.containerEl || this.animation !== animation) { + return; + } + + // Build an element for each animated property track. + this.tracks = yield this.getTracks(animation, this.serverTraits); + + // Useful for tests to know when the keyframes have been retrieved. + this.emit("keyframes-retrieved"); + + for (let propertyName in this.tracks) { + let line = createNode({ + parent: this.containerEl, + attributes: {"class": "property"} + }); + let {warning, className} = + this.getPerfDataForProperty(animation, propertyName); + createNode({ + // text-overflow doesn't work in flex items, so we need a second level + // of container to actually have an ellipsis on the name. + // See bug 972664. + parent: createNode({ + parent: line, + attributes: {"class": "name"} + }), + textContent: getCssPropertyName(propertyName), + attributes: {"title": warning, + "class": className} + }); + + // Add the keyframes diagram for this property. + let framesWrapperEl = createNode({ + parent: line, + attributes: {"class": "track-container"} + }); + + let framesEl = createNode({ + parent: framesWrapperEl, + attributes: {"class": "frames"} + }); + + // Scale the list of keyframes according to the current time scale. + let {x, w} = TimeScale.getAnimationDimensions(animation); + framesEl.style.left = `${x}%`; + framesEl.style.width = `${w}%`; + + let keyframesComponent = new Keyframes(); + keyframesComponent.init(framesEl); + keyframesComponent.render({ + keyframes: this.tracks[propertyName], + propertyName: propertyName, + animation: animation + }); + keyframesComponent.on("frame-selected", this.onFrameSelected); + + this.keyframeComponents.push(keyframesComponent); + } + }), + + onFrameSelected: function (e, args) { + // Relay the event up, it's needed in parents too. + this.emit(e, args); + } +}; + +/** + * Turn propertyName into property-name. + * @param {String} jsPropertyName A camelcased CSS property name. Typically + * something that comes out of computed styles. E.g. borderBottomColor + * @return {String} The corresponding CSS property name: border-bottom-color + */ +function getCssPropertyName(jsPropertyName) { + return jsPropertyName.replace(/[A-Z]/g, "-$&").toLowerCase(); +} +exports.getCssPropertyName = getCssPropertyName; diff --git a/devtools/client/animationinspector/components/animation-target-node.js b/devtools/client/animationinspector/components/animation-target-node.js new file mode 100644 index 000000000..c300e9ce7 --- /dev/null +++ b/devtools/client/animationinspector/components/animation-target-node.js @@ -0,0 +1,80 @@ +/* -*- 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 {Task} = require("devtools/shared/task"); +const EventEmitter = require("devtools/shared/event-emitter"); +const {DomNodePreview} = require("devtools/client/inspector/shared/dom-node-preview"); + +// Map dom node fronts by animation fronts so we don't have to get them from the +// walker every time the timeline is refreshed. +var nodeFronts = new WeakMap(); + +/** + * UI component responsible for displaying a preview of the target dom node of + * a given animation. + * Accepts the same parameters as the DomNodePreview component. See + * devtools/client/inspector/shared/dom-node-preview.js for documentation. + */ +function AnimationTargetNode(inspector, options) { + this.inspector = inspector; + this.previewer = new DomNodePreview(inspector, options); + EventEmitter.decorate(this); +} + +exports.AnimationTargetNode = AnimationTargetNode; + +AnimationTargetNode.prototype = { + init: function (containerEl) { + this.previewer.init(containerEl); + this.isDestroyed = false; + }, + + destroy: function () { + this.previewer.destroy(); + this.inspector = null; + this.isDestroyed = true; + }, + + render: Task.async(function* (playerFront) { + // Get the nodeFront from the cache if it was stored previously. + let nodeFront = nodeFronts.get(playerFront); + + // Try and get it from the playerFront directly next. + if (!nodeFront) { + nodeFront = playerFront.animationTargetNodeFront; + } + + // Finally, get it from the walkerActor if it wasn't found. + if (!nodeFront) { + try { + nodeFront = yield this.inspector.walker.getNodeFromActor( + playerFront.actorID, ["node"]); + } catch (e) { + // If an error occured while getting the nodeFront and if it can't be + // attributed to the panel having been destroyed in the meantime, this + // error needs to be logged and render needs to stop. + if (!this.isDestroyed) { + console.error(e); + } + return; + } + + // In all cases, if by now the panel doesn't exist anymore, we need to + // stop rendering too. + if (this.isDestroyed) { + return; + } + } + + // Add the nodeFront to the cache. + nodeFronts.set(playerFront, nodeFront); + + this.previewer.render(nodeFront); + this.emit("target-retrieved"); + }) +}; diff --git a/devtools/client/animationinspector/components/animation-time-block.js b/devtools/client/animationinspector/components/animation-time-block.js new file mode 100644 index 000000000..51392da8f --- /dev/null +++ b/devtools/client/animationinspector/components/animation-time-block.js @@ -0,0 +1,719 @@ +/* -*- 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)" + } + }); +} diff --git a/devtools/client/animationinspector/components/animation-timeline.js b/devtools/client/animationinspector/components/animation-timeline.js new file mode 100644 index 000000000..49995d729 --- /dev/null +++ b/devtools/client/animationinspector/components/animation-timeline.js @@ -0,0 +1,502 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("devtools/shared/event-emitter"); +const { + createNode, + findOptimalTimeInterval, + TimeScale +} = require("devtools/client/animationinspector/utils"); +const {AnimationDetails} = require("devtools/client/animationinspector/components/animation-details"); +const {AnimationTargetNode} = require("devtools/client/animationinspector/components/animation-target-node"); +const {AnimationTimeBlock} = require("devtools/client/animationinspector/components/animation-time-block"); + +// The minimum spacing between 2 time graduation headers in the timeline (px). +const TIME_GRADUATION_MIN_SPACING = 40; +// When the container window is resized, the timeline background gets refreshed, +// but only after a timer, and the timer is reset if the window is continuously +// resized. +const TIMELINE_BACKGROUND_RESIZE_DEBOUNCE_TIMER = 50; + +/** + * UI component responsible for displaying a timeline for animations. + * The timeline is essentially a graph with time along the x axis and animations + * along the y axis. + * The time is represented with a graduation header at the top and a current + * time play head. + * Animations are organized by lines, with a left margin containing the preview + * of the target DOM element the animation applies to. + * The current time play head can be moved by clicking/dragging in the header. + * when this happens, the component emits "current-data-changed" events with the + * new time and state of the timeline. + * + * @param {InspectorPanel} inspector. + * @param {Object} serverTraits The list of server-side capabilities. + */ +function AnimationsTimeline(inspector, serverTraits) { + this.animations = []; + this.targetNodes = []; + this.timeBlocks = []; + this.details = []; + this.inspector = inspector; + this.serverTraits = serverTraits; + + this.onAnimationStateChanged = this.onAnimationStateChanged.bind(this); + this.onScrubberMouseDown = this.onScrubberMouseDown.bind(this); + this.onScrubberMouseUp = this.onScrubberMouseUp.bind(this); + this.onScrubberMouseOut = this.onScrubberMouseOut.bind(this); + this.onScrubberMouseMove = this.onScrubberMouseMove.bind(this); + this.onAnimationSelected = this.onAnimationSelected.bind(this); + this.onWindowResize = this.onWindowResize.bind(this); + this.onFrameSelected = this.onFrameSelected.bind(this); + + EventEmitter.decorate(this); +} + +exports.AnimationsTimeline = AnimationsTimeline; + +AnimationsTimeline.prototype = { + init: function (containerEl) { + this.win = containerEl.ownerDocument.defaultView; + + this.rootWrapperEl = createNode({ + parent: containerEl, + attributes: { + "class": "animation-timeline" + } + }); + + let scrubberContainer = createNode({ + parent: this.rootWrapperEl, + attributes: {"class": "scrubber-wrapper"} + }); + + this.scrubberEl = createNode({ + parent: scrubberContainer, + attributes: { + "class": "scrubber" + } + }); + + this.scrubberHandleEl = createNode({ + parent: this.scrubberEl, + attributes: { + "class": "scrubber-handle" + } + }); + this.scrubberHandleEl.addEventListener("mousedown", + this.onScrubberMouseDown); + + this.headerWrapper = createNode({ + parent: this.rootWrapperEl, + attributes: { + "class": "header-wrapper" + } + }); + + this.timeHeaderEl = createNode({ + parent: this.headerWrapper, + attributes: { + "class": "time-header track-container" + } + }); + + this.timeHeaderEl.addEventListener("mousedown", + this.onScrubberMouseDown); + + this.timeTickEl = createNode({ + parent: this.rootWrapperEl, + attributes: { + "class": "time-body track-container" + } + }); + + this.animationsEl = createNode({ + parent: this.rootWrapperEl, + nodeType: "ul", + attributes: { + "class": "animations" + } + }); + + this.win.addEventListener("resize", + this.onWindowResize); + }, + + destroy: function () { + this.stopAnimatingScrubber(); + this.unrender(); + + this.win.removeEventListener("resize", + this.onWindowResize); + this.timeHeaderEl.removeEventListener("mousedown", + this.onScrubberMouseDown); + this.scrubberHandleEl.removeEventListener("mousedown", + this.onScrubberMouseDown); + + this.rootWrapperEl.remove(); + this.animations = []; + + this.rootWrapperEl = null; + this.timeHeaderEl = null; + this.animationsEl = null; + this.scrubberEl = null; + this.scrubberHandleEl = null; + this.win = null; + this.inspector = null; + this.serverTraits = null; + }, + + /** + * Destroy sub-components that have been created and stored on this instance. + * @param {String} name An array of components will be expected in this[name] + * @param {Array} handlers An option list of event handlers information that + * should be used to remove these handlers. + */ + destroySubComponents: function (name, handlers = []) { + for (let component of this[name]) { + for (let {event, fn} of handlers) { + component.off(event, fn); + } + component.destroy(); + } + this[name] = []; + }, + + unrender: function () { + for (let animation of this.animations) { + animation.off("changed", this.onAnimationStateChanged); + } + this.stopAnimatingScrubber(); + TimeScale.reset(); + this.destroySubComponents("targetNodes"); + this.destroySubComponents("timeBlocks"); + this.destroySubComponents("details", [{ + event: "frame-selected", + fn: this.onFrameSelected + }]); + this.animationsEl.innerHTML = ""; + }, + + onWindowResize: function () { + // Don't do anything if the root element has a width of 0 + if (this.rootWrapperEl.offsetWidth === 0) { + return; + } + + if (this.windowResizeTimer) { + this.win.clearTimeout(this.windowResizeTimer); + } + + this.windowResizeTimer = this.win.setTimeout(() => { + this.drawHeaderAndBackground(); + }, TIMELINE_BACKGROUND_RESIZE_DEBOUNCE_TIMER); + }, + + onAnimationSelected: function (e, animation) { + let index = this.animations.indexOf(animation); + if (index === -1) { + return; + } + + let el = this.rootWrapperEl; + let animationEl = el.querySelectorAll(".animation")[index]; + let propsEl = el.querySelectorAll(".animated-properties")[index]; + + // Toggle the selected state on this animation. + animationEl.classList.toggle("selected"); + propsEl.classList.toggle("selected"); + + // Render the details component for this animation if it was shown. + if (animationEl.classList.contains("selected")) { + this.details[index].render(animation); + this.emit("animation-selected", animation); + } else { + this.emit("animation-unselected", animation); + } + }, + + /** + * When a frame gets selected, move the scrubber to the corresponding position + */ + onFrameSelected: function (e, {x}) { + this.moveScrubberTo(x, true); + }, + + onScrubberMouseDown: function (e) { + this.moveScrubberTo(e.pageX); + this.win.addEventListener("mouseup", this.onScrubberMouseUp); + this.win.addEventListener("mouseout", this.onScrubberMouseOut); + this.win.addEventListener("mousemove", this.onScrubberMouseMove); + + // Prevent text selection while dragging. + e.preventDefault(); + }, + + onScrubberMouseUp: function () { + this.cancelTimeHeaderDragging(); + }, + + onScrubberMouseOut: function (e) { + // Check that mouseout happened on the window itself, and if yes, cancel + // the dragging. + if (!this.win.document.contains(e.relatedTarget)) { + this.cancelTimeHeaderDragging(); + } + }, + + cancelTimeHeaderDragging: function () { + this.win.removeEventListener("mouseup", this.onScrubberMouseUp); + this.win.removeEventListener("mouseout", this.onScrubberMouseOut); + this.win.removeEventListener("mousemove", this.onScrubberMouseMove); + }, + + onScrubberMouseMove: function (e) { + this.moveScrubberTo(e.pageX); + }, + + moveScrubberTo: function (pageX, noOffset) { + this.stopAnimatingScrubber(); + + // The offset needs to be in % and relative to the timeline's area (so we + // subtract the scrubber's left offset, which is equal to the sidebar's + // width). + let offset = pageX; + if (!noOffset) { + offset -= this.timeHeaderEl.offsetLeft; + } + offset = offset * 100 / this.timeHeaderEl.offsetWidth; + if (offset < 0) { + offset = 0; + } + + this.scrubberEl.style.left = offset + "%"; + + let time = TimeScale.distanceToRelativeTime(offset); + + this.emit("timeline-data-changed", { + isPaused: true, + isMoving: false, + isUserDrag: true, + time: time + }); + }, + + getCompositorStatusClassName: function (state) { + let className = state.isRunningOnCompositor + ? " fast-track" + : ""; + + if (state.isRunningOnCompositor && state.propertyState) { + className += + state.propertyState.some(propState => !propState.runningOnCompositor) + ? " some-properties" + : " all-properties"; + } + + return className; + }, + + render: function (animations, documentCurrentTime) { + this.unrender(); + + this.animations = animations; + if (!this.animations.length) { + return; + } + + // Loop first to set the time scale for all current animations. + for (let {state} of animations) { + TimeScale.addAnimation(state); + } + + this.drawHeaderAndBackground(); + + for (let animation of this.animations) { + animation.on("changed", this.onAnimationStateChanged); + // Each line contains the target animated node and the animation time + // block. + let animationEl = createNode({ + parent: this.animationsEl, + nodeType: "li", + attributes: { + "class": "animation " + + animation.state.type + + this.getCompositorStatusClassName(animation.state) + } + }); + + // Right below the line is a hidden-by-default line for displaying the + // inline keyframes. + let detailsEl = createNode({ + parent: this.animationsEl, + nodeType: "li", + attributes: { + "class": "animated-properties " + animation.state.type + } + }); + + let details = new AnimationDetails(this.serverTraits); + details.init(detailsEl); + details.on("frame-selected", this.onFrameSelected); + this.details.push(details); + + // Left sidebar for the animated node. + let animatedNodeEl = createNode({ + parent: animationEl, + attributes: { + "class": "target" + } + }); + + // Draw the animated node target. + let targetNode = new AnimationTargetNode(this.inspector, {compact: true}); + targetNode.init(animatedNodeEl); + targetNode.render(animation); + this.targetNodes.push(targetNode); + + // Right-hand part contains the timeline itself (called time-block here). + let timeBlockEl = createNode({ + parent: animationEl, + attributes: { + "class": "time-block track-container" + } + }); + + // Draw the animation time block. + let timeBlock = new AnimationTimeBlock(); + timeBlock.init(timeBlockEl); + timeBlock.render(animation); + this.timeBlocks.push(timeBlock); + + timeBlock.on("selected", this.onAnimationSelected); + } + + // Use the document's current time to position the scrubber (if the server + // doesn't provide it, hide the scrubber entirely). + // Note that because the currentTime was sent via the protocol, some time + // may have gone by since then, and so the scrubber might be a bit late. + if (!documentCurrentTime) { + this.scrubberEl.style.display = "none"; + } else { + this.scrubberEl.style.display = "block"; + this.startAnimatingScrubber(this.wasRewound() + ? TimeScale.minStartTime + : documentCurrentTime); + } + }, + + isAtLeastOneAnimationPlaying: function () { + return this.animations.some(({state}) => state.playState === "running"); + }, + + wasRewound: function () { + return !this.isAtLeastOneAnimationPlaying() && + this.animations.every(({state}) => state.currentTime === 0); + }, + + hasInfiniteAnimations: function () { + return this.animations.some(({state}) => !state.iterationCount); + }, + + startAnimatingScrubber: function (time) { + let isOutOfBounds = time < TimeScale.minStartTime || + time > TimeScale.maxEndTime; + let isAllPaused = !this.isAtLeastOneAnimationPlaying(); + let hasInfinite = this.hasInfiniteAnimations(); + + let x = TimeScale.startTimeToDistance(time); + if (x > 100 && !hasInfinite) { + x = 100; + } + this.scrubberEl.style.left = x + "%"; + + // Only stop the scrubber if it's out of bounds or all animations have been + // paused, but not if at least an animation is infinite. + if (isAllPaused || (isOutOfBounds && !hasInfinite)) { + this.stopAnimatingScrubber(); + this.emit("timeline-data-changed", { + isPaused: !this.isAtLeastOneAnimationPlaying(), + isMoving: false, + isUserDrag: false, + time: TimeScale.distanceToRelativeTime(x) + }); + return; + } + + this.emit("timeline-data-changed", { + isPaused: false, + isMoving: true, + isUserDrag: false, + time: TimeScale.distanceToRelativeTime(x) + }); + + let now = this.win.performance.now(); + this.rafID = this.win.requestAnimationFrame(() => { + if (!this.rafID) { + // In case the scrubber was stopped in the meantime. + return; + } + this.startAnimatingScrubber(time + this.win.performance.now() - now); + }); + }, + + stopAnimatingScrubber: function () { + if (this.rafID) { + this.win.cancelAnimationFrame(this.rafID); + this.rafID = null; + } + }, + + onAnimationStateChanged: function () { + // For now, simply re-render the component. The animation front's state has + // already been updated. + this.render(this.animations); + }, + + drawHeaderAndBackground: function () { + let width = this.timeHeaderEl.offsetWidth; + let animationDuration = TimeScale.maxEndTime - TimeScale.minStartTime; + let minTimeInterval = TIME_GRADUATION_MIN_SPACING * + animationDuration / width; + let intervalLength = findOptimalTimeInterval(minTimeInterval); + let intervalWidth = intervalLength * width / animationDuration; + + // And the time graduation header. + this.timeHeaderEl.innerHTML = ""; + this.timeTickEl.innerHTML = ""; + + for (let i = 0; i <= width / intervalWidth; i++) { + let pos = 100 * i * intervalWidth / width; + + // This element is the header of time tick for displaying animation + // duration time. + createNode({ + parent: this.timeHeaderEl, + nodeType: "span", + attributes: { + "class": "header-item", + "style": `left:${pos}%` + }, + textContent: TimeScale.formatTime(TimeScale.distanceToRelativeTime(pos)) + }); + + // This element is displayed as a vertical line separator corresponding + // the header of time tick for indicating time slice for animation + // iterations. + createNode({ + parent: this.timeTickEl, + nodeType: "span", + attributes: { + "class": "time-tick", + "style": `left:${pos}%` + } + }); + } + } +}; diff --git a/devtools/client/animationinspector/components/keyframes.js b/devtools/client/animationinspector/components/keyframes.js new file mode 100644 index 000000000..a017935a3 --- /dev/null +++ b/devtools/client/animationinspector/components/keyframes.js @@ -0,0 +1,81 @@ +/* -*- 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} = require("devtools/client/animationinspector/utils"); + +/** + * UI component responsible for displaying a list of keyframes. + */ +function Keyframes() { + EventEmitter.decorate(this); + this.onClick = this.onClick.bind(this); +} + +exports.Keyframes = Keyframes; + +Keyframes.prototype = { + init: function (containerEl) { + this.containerEl = containerEl; + + this.keyframesEl = createNode({ + parent: this.containerEl, + attributes: {"class": "keyframes"} + }); + + this.containerEl.addEventListener("click", this.onClick); + }, + + destroy: function () { + this.containerEl.removeEventListener("click", this.onClick); + this.keyframesEl.remove(); + this.containerEl = this.keyframesEl = this.animation = null; + }, + + render: function ({keyframes, propertyName, animation}) { + this.keyframes = keyframes; + this.propertyName = propertyName; + this.animation = animation; + + let iterationStartOffset = + animation.state.iterationStart % 1 == 0 + ? 0 + : 1 - animation.state.iterationStart % 1; + + this.keyframesEl.classList.add(animation.state.type); + for (let frame of this.keyframes) { + let offset = frame.offset + iterationStartOffset; + createNode({ + parent: this.keyframesEl, + attributes: { + "class": "frame", + "style": `left:${offset * 100}%;`, + "data-offset": frame.offset, + "data-property": propertyName, + "title": frame.value + } + }); + } + }, + + onClick: function (e) { + // If the click happened on a frame, tell our parent about it. + if (!e.target.classList.contains("frame")) { + return; + } + + e.stopPropagation(); + this.emit("frame-selected", { + animation: this.animation, + propertyName: this.propertyName, + offset: parseFloat(e.target.dataset.offset), + value: e.target.getAttribute("title"), + x: e.target.offsetLeft + e.target.closest(".frames").offsetLeft + }); + } +}; diff --git a/devtools/client/animationinspector/components/moz.build b/devtools/client/animationinspector/components/moz.build new file mode 100644 index 000000000..2265f8c28 --- /dev/null +++ b/devtools/client/animationinspector/components/moz.build @@ -0,0 +1,12 @@ +# 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( + 'animation-details.js', + 'animation-target-node.js', + 'animation-time-block.js', + 'animation-timeline.js', + 'keyframes.js', + 'rate-selector.js' +) diff --git a/devtools/client/animationinspector/components/rate-selector.js b/devtools/client/animationinspector/components/rate-selector.js new file mode 100644 index 000000000..e46664e6a --- /dev/null +++ b/devtools/client/animationinspector/components/rate-selector.js @@ -0,0 +1,105 @@ +/* -*- 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} = require("devtools/client/animationinspector/utils"); +const { LocalizationHelper } = require("devtools/shared/l10n"); +const L10N = + new LocalizationHelper("devtools/client/locales/animationinspector.properties"); + +// List of playback rate presets displayed in the timeline toolbar. +const PLAYBACK_RATES = [.1, .25, .5, 1, 2, 5, 10]; + +/** + * UI component responsible for displaying a playback rate selector UI. + * The rendering logic is such that a predefined list of rates is generated. + * If *all* animations passed to render share the same rate, then that rate is + * selected in the <select> element, otherwise, the empty value is selected. + * If the rate that all animations share isn't part of the list of predefined + * rates, than that rate is added to the list. + */ +function RateSelector() { + this.onRateChanged = this.onRateChanged.bind(this); + EventEmitter.decorate(this); +} + +exports.RateSelector = RateSelector; + +RateSelector.prototype = { + init: function (containerEl) { + this.selectEl = createNode({ + parent: containerEl, + nodeType: "select", + attributes: { + "class": "devtools-button", + "title": L10N.getStr("timeline.rateSelectorTooltip") + } + }); + + this.selectEl.addEventListener("change", this.onRateChanged); + }, + + destroy: function () { + this.selectEl.removeEventListener("change", this.onRateChanged); + this.selectEl.remove(); + this.selectEl = null; + }, + + getAnimationsRates: function (animations) { + return sortedUnique(animations.map(a => a.state.playbackRate)); + }, + + getAllRates: function (animations) { + let animationsRates = this.getAnimationsRates(animations); + if (animationsRates.length > 1) { + return PLAYBACK_RATES; + } + + return sortedUnique(PLAYBACK_RATES.concat(animationsRates)); + }, + + render: function (animations) { + let allRates = this.getAnimationsRates(animations); + let hasOneRate = allRates.length === 1; + + this.selectEl.innerHTML = ""; + + if (!hasOneRate) { + // When the animations displayed have mixed playback rates, we can't + // select any of the predefined ones, instead, insert an empty rate. + createNode({ + parent: this.selectEl, + nodeType: "option", + attributes: {value: "", selector: "true"}, + textContent: "-" + }); + } + for (let rate of this.getAllRates(animations)) { + let option = createNode({ + parent: this.selectEl, + nodeType: "option", + attributes: {value: rate}, + textContent: L10N.getFormatStr("player.playbackRateLabel", rate) + }); + + // If there's only one rate and this is the option for it, select it. + if (hasOneRate && rate === allRates[0]) { + option.setAttribute("selected", "true"); + } + } + }, + + onRateChanged: function () { + let rate = parseFloat(this.selectEl.value); + if (!isNaN(rate)) { + this.emit("rate-changed", rate); + } + } +}; + +let sortedUnique = arr => [...new Set(arr)].sort((a, b) => a > b); |