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