summaryrefslogtreecommitdiffstats
path: root/devtools/client/animationinspector/components/animation-timeline.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/animationinspector/components/animation-timeline.js')
-rw-r--r--devtools/client/animationinspector/components/animation-timeline.js502
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}%`
+ }
+ });
+ }
+ }
+};