diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /devtools/client/animationinspector/components/animation-timeline.js | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'devtools/client/animationinspector/components/animation-timeline.js')
-rw-r--r-- | devtools/client/animationinspector/components/animation-timeline.js | 502 |
1 files changed, 502 insertions, 0 deletions
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}%` + } + }); + } + } +}; |