From 89c946120b3f22c4864f966cffd4068b72d6ee31 Mon Sep 17 00:00:00 2001 From: Patrick Brosset Date: Thu, 11 Jun 2015 15:45:57 +0200 Subject: [PATCH] Bug 1155663 - Show animations as synchronized time blocks in animation inspector; r=bgrins This is the first step towards the animation-inspector UI v3 (bug 1153271). The new UI is still hidden behind a pref, and this change doesn't implement everything that is in the current v2 UI. This introduces a new Timeline graph to represent all currently animated nodes below the currently selected node. v2 used to show them as independent player widgets. With this patch, we now show them as synchronized time blocks on a common time scale. Each animation has a preview of the animated node in the left sidebar, and a time block on the right, the width of which represents its duration. The animation name is also displayed. There's also a time graduations header and background that gives the user information about how long do the animations last. This change does *not* provide a way to know what's the currentTime nor a way to set it yet. This also makes the existing animationinspector tests that still make sense with the new timeline-based UI run with the new UI pref on. --- .../animation-controller.js | 17 +- .../animationinspector/animation-panel.js | 88 +-- .../devtools/animationinspector/components.js | 510 ++++++++++++++---- browser/devtools/animationinspector/moz.build | 1 + ...rowser_animation_empty_on_invalid_nodes.js | 39 +- .../test/browser_animation_panel_exists.js | 9 + ...imation_participate_in_inspector_update.js | 13 +- ...tion_playerWidgets_appear_on_panel_init.js | 11 +- ...er_animation_playerWidgets_target_nodes.js | 23 +- ...er_animation_refresh_on_added_animation.js | 26 +- ..._animation_refresh_on_removed_animation.js | 29 +- .../browser_animation_refresh_when_active.js | 19 +- ...me_nb_of_playerWidgets_and_playerFronts.js | 12 + ...er_animation_shows_player_on_valid_node.js | 14 +- ...owser_animation_target_highlight_select.js | 36 +- ...mation_toggle_button_toggles_animations.js | 2 +- .../test/browser_animation_toolbar_exists.js | 2 +- ..._ui_updates_when_animation_data_changes.js | 60 ++- .../devtools/animationinspector/test/head.js | 87 ++- browser/devtools/animationinspector/utils.js | 135 +++++ .../devtools/animationinspector.properties | 6 + .../shared/devtools/animationinspector.css | 179 +++++- toolkit/devtools/server/actors/animation.js | 129 +++-- 23 files changed, 1206 insertions(+), 241 deletions(-) create mode 100644 browser/devtools/animationinspector/utils.js diff --git a/browser/devtools/animationinspector/animation-controller.js b/browser/devtools/animationinspector/animation-controller.js index bfa01ccc58a..5fb8911ec81 100644 --- a/browser/devtools/animationinspector/animation-controller.js +++ b/browser/devtools/animationinspector/animation-controller.js @@ -114,6 +114,7 @@ let AnimationsController = { "setPlaybackRate"); this.hasTargetNode = yield target.actorHasMethod("domwalker", "getNodeFromActor"); + this.isNewUI = Services.prefs.getBoolPref("devtools.inspector.animationInspectorV3"); if (this.destroyed) { console.warn("Could not fully initialize the AnimationsController"); @@ -240,11 +241,15 @@ let AnimationsController = { for (let {type, player} of changes) { if (type === "added") { this.animationPlayers.push(player); - player.startAutoRefresh(); + if (!this.isNewUI) { + player.startAutoRefresh(); + } } if (type === "removed") { - player.stopAutoRefresh(); + if (!this.isNewUI) { + player.stopAutoRefresh(); + } yield player.release(); let index = this.animationPlayers.indexOf(player); this.animationPlayers.splice(index, 1); @@ -256,12 +261,20 @@ let AnimationsController = { }), startAllAutoRefresh: function() { + if (this.isNewUI) { + return; + } + for (let front of this.animationPlayers) { front.startAutoRefresh(); } }, stopAllAutoRefresh: function() { + if (this.isNewUI) { + return; + } + for (let front of this.animationPlayers) { front.stopAutoRefresh(); } diff --git a/browser/devtools/animationinspector/animation-panel.js b/browser/devtools/animationinspector/animation-panel.js index 7814612ea3c..3d6f1a95b9c 100644 --- a/browser/devtools/animationinspector/animation-panel.js +++ b/browser/devtools/animationinspector/animation-panel.js @@ -3,14 +3,17 @@ /* 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/. */ +/* globals AnimationsController, document, performance, promise, + gToolbox, gInspector, requestAnimationFrame, cancelAnimationFrame, L10N */ "use strict"; +const {createNode} = require("devtools/animationinspector/utils"); const { PlayerMetaDataHeader, PlaybackRateSelector, AnimationTargetNode, - createNode + AnimationsTimeline } = require("devtools/animationinspector/components"); /** @@ -22,7 +25,8 @@ let AnimationsPanel = { initialize: Task.async(function*() { if (AnimationsController.destroyed) { - console.warn("Could not initialize the animation-panel, controller was destroyed"); + console.warn("Could not initialize the animation-panel, controller " + + "was destroyed"); return; } if (this.initialized) { @@ -45,13 +49,18 @@ let AnimationsPanel = { this.togglePicker = hUtils.togglePicker.bind(hUtils); this.onPickerStarted = this.onPickerStarted.bind(this); this.onPickerStopped = this.onPickerStopped.bind(this); - this.createPlayerWidgets = this.createPlayerWidgets.bind(this); + this.refreshAnimations = this.refreshAnimations.bind(this); this.toggleAll = this.toggleAll.bind(this); this.onTabNavigated = this.onTabNavigated.bind(this); this.startListeners(); - yield this.createPlayerWidgets(); + if (AnimationsController.isNewUI) { + this.animationsTimelineComponent = new AnimationsTimeline(gInspector); + this.animationsTimelineComponent.init(this.playersEl); + } + + yield this.refreshAnimations(); this.initialized.resolve(); @@ -69,6 +78,11 @@ let AnimationsPanel = { this.destroyed = promise.defer(); this.stopListeners(); + + if (this.animationsTimelineComponent) { + this.animationsTimelineComponent.destroy(); + this.animationsTimelineComponent = null; + } yield this.destroyPlayerWidgets(); this.playersEl = this.errorMessageEl = null; @@ -79,7 +93,7 @@ let AnimationsPanel = { startListeners: function() { AnimationsController.on(AnimationsController.PLAYERS_UPDATED_EVENT, - this.createPlayerWidgets); + this.refreshAnimations); this.pickerButtonEl.addEventListener("click", this.togglePicker, false); gToolbox.on("picker-started", this.onPickerStarted); @@ -91,7 +105,7 @@ let AnimationsPanel = { stopListeners: function() { AnimationsController.off(AnimationsController.PLAYERS_UPDATED_EVENT, - this.createPlayerWidgets); + this.refreshAnimations); this.pickerButtonEl.removeEventListener("click", this.togglePicker, false); gToolbox.off("picker-started", this.onPickerStarted); @@ -122,16 +136,18 @@ let AnimationsPanel = { toggleAll: Task.async(function*() { let btnClass = this.toggleAllButtonEl.classList; - // Toggling all animations is async and it may be some time before each of - // the current players get their states updated, so toggle locally too, to - // avoid the timelines from jumping back and forth. - if (this.playerWidgets) { - let currentWidgetStateChange = []; - for (let widget of this.playerWidgets) { - currentWidgetStateChange.push(btnClass.contains("paused") - ? widget.play() : widget.pause()); + if (!AnimationsController.isNewUI) { + // Toggling all animations is async and it may be some time before each of + // the current players get their states updated, so toggle locally too, to + // avoid the timelines from jumping back and forth. + if (this.playerWidgets) { + let currentWidgetStateChange = []; + for (let widget of this.playerWidgets) { + currentWidgetStateChange.push(btnClass.contains("paused") + ? widget.play() : widget.pause()); + } + yield promise.all(currentWidgetStateChange).catch(Cu.reportError); } - yield promise.all(currentWidgetStateChange).catch(Cu.reportError); } btnClass.toggle("paused"); @@ -142,14 +158,21 @@ let AnimationsPanel = { this.toggleAllButtonEl.classList.remove("paused"); }, - createPlayerWidgets: Task.async(function*() { + refreshAnimations: Task.async(function*() { let done = gInspector.updating("animationspanel"); // Empty the whole panel first. this.hideErrorMessage(); yield this.destroyPlayerWidgets(); - // If there are no players to show, show the error message instead and return. + // Re-render the timeline component. + if (this.animationsTimelineComponent) { + this.animationsTimelineComponent.render( + AnimationsController.animationPlayers); + } + + // If there are no players to show, show the error message instead and + // return. if (!AnimationsController.animationPlayers.length) { this.displayErrorMessage(); this.emit(this.UI_UPDATED_EVENT); @@ -157,17 +180,21 @@ let AnimationsPanel = { return; } - // Otherwise, create player widgets. - this.playerWidgets = []; - let initPromises = []; + // Otherwise, create player widgets (only when isNewUI is false, the + // timeline has already been re-rendered). + if (!AnimationsController.isNewUI) { + this.playerWidgets = []; + let initPromises = []; - for (let player of AnimationsController.animationPlayers) { - let widget = new PlayerWidget(player, this.playersEl); - initPromises.push(widget.initialize()); - this.playerWidgets.push(widget); + for (let player of AnimationsController.animationPlayers) { + let widget = new PlayerWidget(player, this.playersEl); + initPromises.push(widget.initialize()); + this.playerWidgets.push(widget); + } + + yield initPromises; } - yield initPromises; this.emit(this.UI_UPDATED_EVENT); done(); }), @@ -392,9 +419,8 @@ PlayerWidget.prototype = { onPlayPauseBtnClick: function() { if (this.player.state.playState === "running") { return this.pause(); - } else { - return this.play(); } + return this.play(); }, onRewindBtnClick: function() { @@ -406,7 +432,7 @@ PlayerWidget.prototype = { let time = state.duration; if (state.iterationCount) { - time = state.iterationCount * state.duration; + time = state.iterationCount * state.duration; } this.setCurrentTime(time, true); }, @@ -466,7 +492,8 @@ PlayerWidget.prototype = { */ setCurrentTime: Task.async(function*(time, shouldPause) { if (!AnimationsController.hasSetCurrentTime) { - throw new Error("This server version doesn't support setting animations' currentTime"); + throw new Error("This server version doesn't support setting " + + "animations' currentTime"); } if (shouldPause) { @@ -492,7 +519,8 @@ PlayerWidget.prototype = { */ setPlaybackRate: function(rate) { if (!AnimationsController.hasSetPlaybackRate) { - throw new Error("This server version doesn't support setting animations' playbackRate"); + throw new Error("This server version doesn't support setting " + + "animations' playbackRate"); } return this.player.setPlaybackRate(rate); diff --git a/browser/devtools/animationinspector/components.js b/browser/devtools/animationinspector/components.js index 78ebda5527f..f2c155a6744 100644 --- a/browser/devtools/animationinspector/components.js +++ b/browser/devtools/animationinspector/components.js @@ -3,6 +3,7 @@ /* 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/. */ +/* globals ViewHelpers */ "use strict"; @@ -19,11 +20,19 @@ // 4. destroy the component: // c.destroy(); -const {Cu} = require('chrome'); +const {Cu} = require("chrome"); Cu.import("resource:///modules/devtools/ViewHelpers.jsm"); +const { + createNode, + drawGraphElementBackground, + findOptimalTimeInterval +} = require("devtools/animationinspector/utils"); const STRINGS_URI = "chrome://browser/locale/devtools/animationinspector.properties"; const L10N = new ViewHelpers.L10N(STRINGS_URI); +const MILLIS_TIME_FORMAT_MAX_DURATION = 4000; +// The minimum spacing between 2 time graduation headers in the timeline (ms). +const TIME_GRADUATION_MIN_SPACING = 40; /** * UI component responsible for displaying and updating the player meta-data: @@ -75,9 +84,9 @@ PlayerMetaDataHeader.prototype = { // Animation duration. this.durationLabel = createNode({ parent: metaData, - nodeType: "span" + nodeType: "span", + textContent: L10N.getStr("player.animationDurationLabel") }); - this.durationLabel.textContent = L10N.getStr("player.animationDurationLabel"); this.durationValue = createNode({ parent: metaData, @@ -90,9 +99,9 @@ PlayerMetaDataHeader.prototype = { nodeType: "span", attributes: { "style": "display:none;" - } + }, + textContent: L10N.getStr("player.animationDelayLabel") }); - this.delayLabel.textContent = L10N.getStr("player.animationDelayLabel"); this.delayValue = createNode({ parent: metaData, @@ -106,9 +115,9 @@ PlayerMetaDataHeader.prototype = { nodeType: "span", attributes: { "style": "display:none;" - } + }, + textContent: L10N.getStr("player.animationIterationCountLabel") }); - this.iterationLabel.textContent = L10N.getStr("player.animationIterationCountLabel"); this.iterationValue = createNode({ parent: metaData, @@ -224,7 +233,7 @@ PlaybackRateSelector.prototype = { * different from the existing presets. */ getCurrentPresets: function({playbackRate}) { - return [...new Set([...this.PRESETS, playbackRate])].sort((a,b) => a > b); + return [...new Set([...this.PRESETS, playbackRate])].sort((a, b) => a > b); }, render: function(state) { @@ -248,9 +257,9 @@ PlaybackRateSelector.prototype = { nodeType: "option", attributes: { value: preset, - } + }, + textContent: L10N.getFormatStr("player.playbackRateLabel", preset) }); - option.textContent = L10N.getFormatStr("player.playbackRateLabel", preset); if (preset === state.playbackRate) { option.setAttribute("selected", ""); } @@ -261,7 +270,7 @@ PlaybackRateSelector.prototype = { this.currentRate = state.playbackRate; }, - onSelectionChanged: function(e) { + onSelectionChanged: function() { this.emit("rate-changed", parseFloat(this.el.value)); } }; @@ -272,9 +281,13 @@ PlaybackRateSelector.prototype = { * @param {InspectorPanel} inspector Requires a reference to the inspector-panel * to highlight and select the node, as well as refresh it when there are * mutations. + * @param {Object} options Supported properties are: + * - compact {Boolean} Defaults to false. If true, nodes will be previewed like + * tag#id.class instead of */ -function AnimationTargetNode(inspector) { +function AnimationTargetNode(inspector, options={}) { this.inspector = inspector; + this.options = options; this.onPreviewMouseOver = this.onPreviewMouseOver.bind(this); this.onPreviewMouseOut = this.onPreviewMouseOut.bind(this); @@ -313,7 +326,9 @@ AnimationTargetNode.prototype = { nodeType: "span" }); - this.previewEl.appendChild(document.createTextNode("<")); + if (!this.options.compact) { + this.previewEl.appendChild(document.createTextNode("<")); + } // Tag name. this.tagNameEl = createNode({ @@ -330,15 +345,26 @@ AnimationTargetNode.prototype = { nodeType: "span" }); - createNode({ - parent: this.idEl, - nodeType: "span", - attributes: { - "class": "attribute-name theme-fg-color2" - } - }).textContent = "id"; - - this.idEl.appendChild(document.createTextNode("=\"")); + if (!this.options.compact) { + createNode({ + parent: this.idEl, + nodeType: "span", + attributes: { + "class": "attribute-name theme-fg-color2" + }, + textContent: "id" + }); + this.idEl.appendChild(document.createTextNode("=\"")); + } else { + createNode({ + parent: this.idEl, + nodeType: "span", + attributes: { + "class": "theme-fg-color2" + }, + textContent: "#" + }); + } createNode({ parent: this.idEl, @@ -348,7 +374,9 @@ AnimationTargetNode.prototype = { } }); - this.idEl.appendChild(document.createTextNode("\"")); + if (!this.options.compact) { + this.idEl.appendChild(document.createTextNode("\"")); + } // Class attribute container. this.classEl = createNode({ @@ -356,15 +384,26 @@ AnimationTargetNode.prototype = { nodeType: "span" }); - createNode({ - parent: this.classEl, - nodeType: "span", - attributes: { - "class": "attribute-name theme-fg-color2" - } - }).textContent = "class"; - - this.classEl.appendChild(document.createTextNode("=\"")); + if (!this.options.compact) { + createNode({ + parent: this.classEl, + nodeType: "span", + attributes: { + "class": "attribute-name theme-fg-color2" + }, + textContent: "class" + }); + this.classEl.appendChild(document.createTextNode("=\"")); + } else { + createNode({ + parent: this.classEl, + nodeType: "span", + attributes: { + "class": "theme-fg-color6" + }, + textContent: "." + }); + } createNode({ parent: this.classEl, @@ -374,9 +413,10 @@ AnimationTargetNode.prototype = { } }); - this.classEl.appendChild(document.createTextNode("\"")); - - this.previewEl.appendChild(document.createTextNode(">")); + if (!this.options.compact) { + this.classEl.appendChild(document.createTextNode("\"")); + this.previewEl.appendChild(document.createTextNode(">")); + } // Init events for highlighting and selecting the node. this.previewEl.addEventListener("mouseover", this.onPreviewMouseOver); @@ -430,73 +470,357 @@ AnimationTargetNode.prototype = { } }, - render: function(playerFront) { + render: Task.async(function*(playerFront) { this.playerFront = playerFront; - this.inspector.walker.getNodeFromActor(playerFront.actorID, ["node"]).then(nodeFront => { - // We might have been destroyed in the meantime, or the node might not be found. - if (!this.el || !nodeFront) { - return; - } + this.nodeFront = undefined; - this.nodeFront = nodeFront; - let {tagName, attributes} = nodeFront; - - this.tagNameEl.textContent = tagName.toLowerCase(); - - let idIndex = attributes.findIndex(({name}) => name === "id"); - if (idIndex > -1 && attributes[idIndex].value) { - this.idEl.querySelector(".attribute-value").textContent = - attributes[idIndex].value; - this.idEl.style.display = "inline"; - } else { - this.idEl.style.display = "none"; - } - - let classIndex = attributes.findIndex(({name}) => name === "class"); - if (classIndex > -1 && attributes[classIndex].value) { - this.classEl.querySelector(".attribute-value").textContent = - attributes[classIndex].value; - this.classEl.style.display = "inline"; - } else { - this.classEl.style.display = "none"; - } - - this.emit("target-retrieved"); - }, e => { - this.nodeFront = null; + try { + this.nodeFront = yield this.inspector.walker.getNodeFromActor( + playerFront.actorID, ["node"]); + } catch (e) { + // We might have been destroyed in the meantime, or the node might not be + // found. if (!this.el) { - console.warn("Cound't retrieve the animation target node, widget destroyed"); - } else { - console.error(e); + console.warn("Cound't retrieve the animation target node, widget " + + "destroyed"); } - }); + console.error(e); + return; + } + + if (!this.nodeFront || !this.el) { + return; + } + + let {tagName, attributes} = this.nodeFront; + + this.tagNameEl.textContent = tagName.toLowerCase(); + + let idIndex = attributes.findIndex(({name}) => name === "id"); + if (idIndex > -1 && attributes[idIndex].value) { + this.idEl.querySelector(".attribute-value").textContent = + attributes[idIndex].value; + this.idEl.style.display = "inline"; + } else { + this.idEl.style.display = "none"; + } + + let classIndex = attributes.findIndex(({name}) => name === "class"); + if (classIndex > -1 && attributes[classIndex].value) { + let value = attributes[classIndex].value; + if (this.options.compact) { + value = value.split(" ").join("."); + } + + this.classEl.querySelector(".attribute-value").textContent = value; + this.classEl.style.display = "inline"; + } else { + this.classEl.style.display = "none"; + } + + this.emit("target-retrieved"); + }) +}; + +/** + * The TimeScale helper object is used to know which size should something be + * displayed with in the animation panel, depending on the animations that are + * currently displayed. + * If there are 5 animations displayed, and the first one starts at 10000ms and + * the last one ends at 20000ms, then this helper can be used to convert any + * time in this range to a distance in pixels. + * + * For the helper to know how to convert, it needs to know all the animations. + * Whenever a new animation is added to the panel, addAnimation(state) should be + * called. reset() can be called to start over. + */ +let TimeScale = { + minStartTime: Infinity, + maxEndTime: 0, + + /** + * Add a new animation to time scale. + * @param {Object} state A PlayerFront.state object. + */ + addAnimation: function({startTime, delay, duration, iterationCount}) { + this.minStartTime = Math.min(this.minStartTime, startTime); + let length = delay + (duration * (!iterationCount ? 1 : iterationCount)); + this.maxEndTime = Math.max(this.maxEndTime, startTime + length); + }, + + /** + * Reset the current time scale. + */ + reset: function() { + this.minStartTime = Infinity; + this.maxEndTime = 0; + }, + + /** + * Convert a startTime to a distance in pixels, in the current time scale. + * @param {Number} time + * @param {Number} containerWidth The width of the container element. + * @return {Number} + */ + startTimeToDistance: function(time, containerWidth) { + time -= this.minStartTime; + return this.durationToDistance(time, containerWidth); + }, + + /** + * Convert a duration to a distance in pixels, in the current time scale. + * @param {Number} time + * @param {Number} containerWidth The width of the container element. + * @return {Number} + */ + durationToDistance: function(duration, containerWidth) { + return containerWidth * duration / (this.maxEndTime - this.minStartTime); + }, + + /** + * Convert a distance in pixels to a time, in the current time scale. + * @param {Number} distance + * @param {Number} containerWidth The width of the container element. + * @return {Number} + */ + distanceToTime: function(distance, containerWidth) { + return this.minStartTime + + ((this.maxEndTime - this.minStartTime) * distance / containerWidth); + }, + + /** + * Convert a distance in pixels to a time, in the current time scale. + * The time will be relative to the current minimum start time. + * @param {Number} distance + * @param {Number} containerWidth The width of the container element. + * @return {Number} + */ + distanceToRelativeTime: function(distance, containerWidth) { + let time = this.distanceToTime(distance, containerWidth); + return time - this.minStartTime; + }, + + /** + * Depending on the time scale, format the given time as milliseconds or + * seconds. + * @param {Number} time + * @return {String} The formatted time string. + */ + formatTime: function(time) { + let duration = this.maxEndTime - this.minStartTime; + + // Format in milliseconds if the total duration is short enough. + if (duration <= MILLIS_TIME_FORMAT_MAX_DURATION) { + return L10N.getFormatStr("timeline.timeGraduationLabel", time.toFixed(0)); + } + + // Otherwise format in seconds. + return L10N.getFormatStr("player.timeLabel", (time / 1000).toFixed(1)); } }; /** - * DOM node creation helper function. - * @param {Object} Options to customize the node to be created. - * - nodeType {String} Optional, defaults to "div", - * - attributes {Object} Optional attributes object like - * {attrName1:value1, attrName2: value2, ...} - * - parent {DOMNode} Mandatory node to append the newly created node to. - * @return {DOMNode} The newly created node. + * 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. */ -function createNode(options) { - if (!options.parent) { - throw new Error("Missing parent DOMNode to create new node"); - } +function AnimationsTimeline(inspector) { + this.animations = []; + this.targetNodes = []; + this.inspector = inspector; - let type = options.nodeType || "div"; - let node = options.parent.ownerDocument.createElement(type); - - for (let name in options.attributes || {}) { - let value = options.attributes[name]; - node.setAttribute(name, value); - } - - options.parent.appendChild(node); - return node; + this.onAnimationStateChanged = this.onAnimationStateChanged.bind(this); } -exports.createNode = createNode; +exports.AnimationsTimeline = AnimationsTimeline; + +AnimationsTimeline.prototype = { + init: function(containerEl) { + this.win = containerEl.ownerDocument.defaultView; + + this.rootWrapperEl = createNode({ + parent: containerEl, + attributes: { + "class": "animation-timeline" + } + }); + + this.timeHeaderEl = createNode({ + parent: this.rootWrapperEl, + attributes: { + "class": "time-header" + } + }); + + this.animationsEl = createNode({ + parent: this.rootWrapperEl, + nodeType: "ul", + attributes: { + "class": "animations" + } + }); + }, + + destroy: function() { + this.unrender(); + + this.rootWrapperEl.remove(); + this.animations = []; + + this.rootWrapperEl = null; + this.timeHeaderEl = null; + this.animationsEl = null; + this.win = null; + this.inspector = null; + }, + + destroyTargetNodes: function() { + for (let targetNode of this.targetNodes) { + targetNode.destroy(); + } + this.targetNodes = []; + }, + + unrender: function() { + for (let animation of this.animations) { + animation.off("changed", this.onAnimationStateChanged); + } + + TimeScale.reset(); + this.destroyTargetNodes(); + this.animationsEl.innerHTML = ""; + }, + + render: function(animations) { + 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" + } + }); + + // Left sidebar for the animated node. + let animatedNodeEl = createNode({ + parent: animationEl, + attributes: { + "class": "target" + } + }); + + let timeBlockEl = createNode({ + parent: animationEl, + attributes: { + "class": "time-block" + } + }); + + this.drawTimeBlock(animation, timeBlockEl); + + // Draw the animated node target. + let targetNode = new AnimationTargetNode(this.inspector, {compact: true}); + targetNode.init(animatedNodeEl); + targetNode.render(animation); + + // Save the targetNode so it can be destroyed later. + this.targetNodes.push(targetNode); + } + }, + + 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 scale = width / (TimeScale.maxEndTime - TimeScale.minStartTime); + drawGraphElementBackground(this.win.document, "time-graduations", width, scale); + + // And the time graduation header. + this.timeHeaderEl.innerHTML = ""; + let interval = findOptimalTimeInterval(scale, TIME_GRADUATION_MIN_SPACING); + for (let i = 0; i < width; i += interval) { + createNode({ + parent: this.timeHeaderEl, + nodeType: "span", + attributes: { + "class": "time-tick", + "style": `left:${i}px` + }, + textContent: TimeScale.formatTime( + TimeScale.distanceToRelativeTime(i, width)) + }); + } + }, + + drawTimeBlock: function({state}, el) { + let width = el.offsetWidth; + + // Container for all iterations and delay. Positioned at the right start + // time. + let x = TimeScale.startTimeToDistance(state.startTime + (state.delay || 0), + width); + // With the right width (duration*duration). + let count = state.iterationCount || 1; + let w = TimeScale.durationToDistance(state.duration, width); + + let iterations = createNode({ + parent: el, + attributes: { + "class": "iterations" + (state.iterationCount ? "" : " infinite"), + // Individual iterations are represented by setting the size of the + // repeating linear-gradient. + "style": `left:${x}px; + width:${w * count}px; + background-size:${Math.max(w, 2)}px 100%;` + } + }); + + // The animation name is displayed over the iterations. + createNode({ + parent: iterations, + attributes: { + "class": "name" + }, + textContent: state.name + }); + + // Delay. + if (state.delay) { + let delay = TimeScale.durationToDistance(state.delay, width); + createNode({ + parent: iterations, + attributes: { + "class": "delay", + "style": `left:-${delay}px; + width:${delay}px;` + } + }); + } + } +}; diff --git a/browser/devtools/animationinspector/moz.build b/browser/devtools/animationinspector/moz.build index 4b1e70b7a07..b69b5c83d07 100644 --- a/browser/devtools/animationinspector/moz.build +++ b/browser/devtools/animationinspector/moz.build @@ -8,4 +8,5 @@ BROWSER_CHROME_MANIFESTS += ['test/browser.ini'] EXTRA_JS_MODULES.devtools.animationinspector += [ 'components.js', + 'utils.js', ] diff --git a/browser/devtools/animationinspector/test/browser_animation_empty_on_invalid_nodes.js b/browser/devtools/animationinspector/test/browser_animation_empty_on_invalid_nodes.js index 926bf12844d..f8d9be8e510 100644 --- a/browser/devtools/animationinspector/test/browser_animation_empty_on_invalid_nodes.js +++ b/browser/devtools/animationinspector/test/browser_animation_empty_on_invalid_nodes.js @@ -8,17 +8,44 @@ add_task(function*() { yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); - let {inspector, panel} = yield openAnimationInspector(); + let {inspector, panel} = yield openAnimationInspector(); + yield testEmptyPanel(inspector, panel); + + ({inspector, panel}) = yield closeAnimationInspectorAndRestartWithNewUI(); + yield testEmptyPanel(inspector, panel, true); +}); + +function* testEmptyPanel(inspector, panel, isNewUI=false) { info("Select node .still and check that the panel is empty"); let stillNode = yield getNodeFront(".still", inspector); + let onUpdated = panel.once(panel.UI_UPDATED_EVENT); yield selectNode(stillNode, inspector); - ok(!panel.playerWidgets || !panel.playerWidgets.length, - "No player widgets displayed for a still node"); + yield onUpdated; + + if (isNewUI) { + is(panel.animationsTimelineComponent.animations.length, 0, + "No animation players stored in the timeline component for a still node"); + is(panel.animationsTimelineComponent.animationsEl.childNodes.length, 0, + "No animation displayed in the timeline component for a still node"); + } else { + ok(!panel.playerWidgets || !panel.playerWidgets.length, + "No player widgets displayed for a still node"); + } info("Select the comment text node and check that the panel is empty"); let commentNode = yield inspector.walker.previousSibling(stillNode); + onUpdated = panel.once(panel.UI_UPDATED_EVENT); yield selectNode(commentNode, inspector); - ok(!panel.playerWidgets || !panel.playerWidgets.length, - "No player widgets displayed for a text node"); -}); + yield onUpdated; + + if (isNewUI) { + is(panel.animationsTimelineComponent.animations.length, 0, + "No animation players stored in the timeline component for a text node"); + is(panel.animationsTimelineComponent.animationsEl.childNodes.length, 0, + "No animation displayed in the timeline component for a text node"); + } else { + ok(!panel.playerWidgets || !panel.playerWidgets.length, + "No player widgets displayed for a text node"); + } +} diff --git a/browser/devtools/animationinspector/test/browser_animation_panel_exists.js b/browser/devtools/animationinspector/test/browser_animation_panel_exists.js index 9b8f2c2db2c..ffd86c72c71 100644 --- a/browser/devtools/animationinspector/test/browser_animation_panel_exists.js +++ b/browser/devtools/animationinspector/test/browser_animation_panel_exists.js @@ -15,4 +15,13 @@ add_task(function*() { ok(panel, "The animation panel exists"); ok(panel.playersEl, "The animation panel has been initialized"); + + ({panel, controller}) = yield closeAnimationInspectorAndRestartWithNewUI(); + + ok(controller, "The animation controller exists"); + ok(controller.animationsFront, "The animation controller has been initialized"); + + ok(panel, "The animation panel exists"); + ok(panel.playersEl, "The animation panel has been initialized"); + ok(panel.animationsTimelineComponent, "The animation panel has been initialized"); }); diff --git a/browser/devtools/animationinspector/test/browser_animation_participate_in_inspector_update.js b/browser/devtools/animationinspector/test/browser_animation_participate_in_inspector_update.js index c04c0610281..3297f88125e 100644 --- a/browser/devtools/animationinspector/test/browser_animation_participate_in_inspector_update.js +++ b/browser/devtools/animationinspector/test/browser_animation_participate_in_inspector_update.js @@ -10,8 +10,15 @@ add_task(function*() { yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); - let {inspector, panel, controller} = yield openAnimationInspector(); + let ui = yield openAnimationInspector(); + yield testEventsOrder(ui); + + ui = yield closeAnimationInspectorAndRestartWithNewUI(); + yield testEventsOrder(ui); +}); + +function* testEventsOrder({inspector, panel, controller}) { info("Listen for the players-updated, ui-updated and inspector-updated events"); let receivedEvents = []; controller.once(controller.PLAYERS_UPDATED_EVENT, () => { @@ -19,7 +26,7 @@ add_task(function*() { }); panel.once(panel.UI_UPDATED_EVENT, () => { receivedEvents.push(panel.UI_UPDATED_EVENT); - }) + }); inspector.once("inspector-updated", () => { receivedEvents.push("inspector-updated"); }); @@ -36,4 +43,4 @@ add_task(function*() { "The second event received was the ui-updated event"); is(receivedEvents[2], "inspector-updated", "The third event received was the inspector-updated event"); -}); +} diff --git a/browser/devtools/animationinspector/test/browser_animation_playerWidgets_appear_on_panel_init.js b/browser/devtools/animationinspector/test/browser_animation_playerWidgets_appear_on_panel_init.js index 8bddf258d34..256c742c40d 100644 --- a/browser/devtools/animationinspector/test/browser_animation_playerWidgets_appear_on_panel_init.js +++ b/browser/devtools/animationinspector/test/browser_animation_playerWidgets_appear_on_panel_init.js @@ -9,7 +9,14 @@ add_task(function*() { yield addTab(TEST_URL_ROOT + "doc_body_animation.html"); - let {panel} = yield openAnimationInspector(); - is(panel.playerWidgets.length, 1, "One animation player is displayed after init"); + let {panel} = yield openAnimationInspector(); + is(panel.playerWidgets.length, 1, + "One animation player is displayed after init"); + + ({panel}) = yield closeAnimationInspectorAndRestartWithNewUI(); + is(panel.animationsTimelineComponent.animations.length, 1, + "One animation is handled by the timeline after init"); + is(panel.animationsTimelineComponent.animationsEl.childNodes.length, 1, + "One animation is displayed after init"); }); diff --git a/browser/devtools/animationinspector/test/browser_animation_playerWidgets_target_nodes.js b/browser/devtools/animationinspector/test/browser_animation_playerWidgets_target_nodes.js index 36d2b54880b..7480247180f 100644 --- a/browser/devtools/animationinspector/test/browser_animation_playerWidgets_target_nodes.js +++ b/browser/devtools/animationinspector/test/browser_animation_playerWidgets_target_nodes.js @@ -27,5 +27,26 @@ add_task(function*() { "The target element's content is correct"); let selectorEl = targetEl.querySelector(".node-selector"); - ok(selectorEl, "The icon to select the target element in the inspector exists"); + ok(selectorEl, + "The icon to select the target element in the inspector exists"); + + info("Test again with the new timeline UI"); + ({inspector, panel}) = yield closeAnimationInspectorAndRestartWithNewUI(); + + info("Select the simple animated node"); + yield selectNode(".animated", inspector); + + let targetNodeComponent = panel.animationsTimelineComponent.targetNodes[0]; + // Make sure to wait for the target-retrieved event if the nodeFront hasn't + // yet been retrieved by the TargetNodeComponent. + if (!targetNodeComponent.nodeFront) { + yield targetNodeComponent.once("target-retrieved"); + } + + is(targetNodeComponent.el.textContent, "div#.ball.animated", + "The target element's content is correct"); + + selectorEl = targetNodeComponent.el.querySelector(".node-selector"); + ok(selectorEl, + "The icon to select the target element in the inspector exists"); }); diff --git a/browser/devtools/animationinspector/test/browser_animation_refresh_on_added_animation.js b/browser/devtools/animationinspector/test/browser_animation_refresh_on_added_animation.js index db276522707..bca5e6f9e17 100644 --- a/browser/devtools/animationinspector/test/browser_animation_refresh_on_added_animation.js +++ b/browser/devtools/animationinspector/test/browser_animation_refresh_on_added_animation.js @@ -8,13 +8,19 @@ add_task(function*() { yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); - let {toolbox, inspector, panel} = yield openAnimationInspector(); + let {inspector, panel} = yield openAnimationInspector(); + yield testRefreshOnNewAnimation(inspector, panel); + + ({inspector, panel}) = yield closeAnimationInspectorAndRestartWithNewUI(); + yield testRefreshOnNewAnimation(inspector, panel); +}); + +function* testRefreshOnNewAnimation(inspector, panel) { info("Select a non animated node"); yield selectNode(".still", inspector); - is(panel.playersEl.querySelectorAll(".player-widget").length, 0, - "There are no player widgets in the panel"); + assertAnimationsDisplayed(panel, 0); info("Listen to the next UI update event"); let onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT); @@ -29,6 +35,14 @@ add_task(function*() { yield onPanelUpdated; ok(true, "The panel update event was fired"); - is(panel.playersEl.querySelectorAll(".player-widget").length, 1, - "There is one player widget in the panel"); -}); + assertAnimationsDisplayed(panel, 1); + + info("Remove the animation class on the node"); + onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT); + yield executeInContent("devtools:test:setAttribute", { + selector: ".ball.animated", + attributeName: "class", + attributeValue: "ball still" + }); + yield onPanelUpdated; +} diff --git a/browser/devtools/animationinspector/test/browser_animation_refresh_on_removed_animation.js b/browser/devtools/animationinspector/test/browser_animation_refresh_on_removed_animation.js index 868c0472fcb..49586206b7c 100644 --- a/browser/devtools/animationinspector/test/browser_animation_refresh_on_removed_animation.js +++ b/browser/devtools/animationinspector/test/browser_animation_refresh_on_removed_animation.js @@ -8,13 +8,21 @@ add_task(function*() { yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); - let {toolbox, inspector, panel} = yield openAnimationInspector(); + let {inspector, panel} = yield openAnimationInspector(); + yield testRefreshOnRemove(inspector, panel); + yield testAddedAnimationWorks(inspector, panel); + + info("Reload and test again with the new UI"); + ({inspector, panel}) = yield closeAnimationInspectorAndRestartWithNewUI(true); + yield testRefreshOnRemove(inspector, panel, true); +}); + +function* testRefreshOnRemove(inspector, panel) { info("Select a animated node"); yield selectNode(".animated", inspector); - is(panel.playersEl.querySelectorAll(".player-widget").length, 1, - "There is one player widget in the panel"); + assertAnimationsDisplayed(panel, 1); info("Listen to the next UI update event"); let onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT); @@ -29,23 +37,24 @@ add_task(function*() { yield onPanelUpdated; ok(true, "The panel update event was fired"); - is(panel.playersEl.querySelectorAll(".player-widget").length, 0, - "There are no player widgets in the panel anymore"); + assertAnimationsDisplayed(panel, 0); info("Add an finite animation on the node again, and wait for it to appear"); onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT); yield executeInContent("devtools:test:setAttribute", { selector: ".test-node", attributeName: "class", - attributeValue: "ball short" + attributeValue: "ball short test-node" }); yield onPanelUpdated; - is(panel.playersEl.querySelectorAll(".player-widget").length, 1, - "There is one player widget in the panel again"); + assertAnimationsDisplayed(panel, 1); +} + +function* testAddedAnimationWorks(inspector, panel) { info("Now wait until the animation finishes"); let widget = panel.playerWidgets[0]; - yield waitForPlayState(widget.player, "finished") + yield waitForPlayState(widget.player, "finished"); is(panel.playersEl.querySelectorAll(".player-widget").length, 1, "There is still a player widget in the panel after the animation finished"); @@ -59,4 +68,4 @@ add_task(function*() { EventUtils.synthesizeMouseAtCenter(input, {type: "mousedown"}, win); yield onPaused; ok(widget.el.classList.contains("paused"), "The widget is in paused mode"); -}); +} diff --git a/browser/devtools/animationinspector/test/browser_animation_refresh_when_active.js b/browser/devtools/animationinspector/test/browser_animation_refresh_when_active.js index dd203b16df0..567f15f2482 100644 --- a/browser/devtools/animationinspector/test/browser_animation_refresh_when_active.js +++ b/browser/devtools/animationinspector/test/browser_animation_refresh_when_active.js @@ -8,8 +8,15 @@ add_task(function*() { yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); - let {toolbox, inspector, panel} = yield openAnimationInspector(); + let {inspector, panel} = yield openAnimationInspector(); + yield testRefresh(inspector, panel); + + ({inspector, panel}) = yield closeAnimationInspectorAndRestartWithNewUI(); + yield testRefresh(inspector, panel); +}); + +function* testRefresh(inspector, panel) { info("Select a non animated node"); yield selectNode(".still", inspector); @@ -19,14 +26,14 @@ add_task(function*() { info("Select the animated node now"); yield selectNode(".animated", inspector); - ok(!panel.playerWidgets || !panel.playerWidgets.length, + assertAnimationsDisplayed(panel, 0, "The panel doesn't show the animation data while inactive"); info("Switch to the animation panel"); inspector.sidebar.select("animationinspector"); yield panel.once(panel.UI_UPDATED_EVENT); - is(panel.playerWidgets.length, 1, + assertAnimationsDisplayed(panel, 1, "The panel shows the animation data after selecting it"); info("Switch again to the rule-view"); @@ -35,13 +42,13 @@ add_task(function*() { info("Select the non animated node again"); yield selectNode(".still", inspector); - is(panel.playerWidgets.length, 1, + assertAnimationsDisplayed(panel, 1, "The panel still shows the previous animation data since it is inactive"); info("Switch to the animation panel again"); inspector.sidebar.select("animationinspector"); yield panel.once(panel.UI_UPDATED_EVENT); - ok(!panel.playerWidgets || !panel.playerWidgets.length, + assertAnimationsDisplayed(panel, 0, "The panel is now empty after refreshing"); -}); +} diff --git a/browser/devtools/animationinspector/test/browser_animation_same_nb_of_playerWidgets_and_playerFronts.js b/browser/devtools/animationinspector/test/browser_animation_same_nb_of_playerWidgets_and_playerFronts.js index 24a00579668..4a245359190 100644 --- a/browser/devtools/animationinspector/test/browser_animation_same_nb_of_playerWidgets_and_playerFronts.js +++ b/browser/devtools/animationinspector/test/browser_animation_same_nb_of_playerWidgets_and_playerFronts.js @@ -22,4 +22,16 @@ add_task(function*() { is(widget.el.parentNode, panel.playersEl, "The player widget has been appended to the panel"); } + + info("Test again with the new UI, making sure the same number of " + + "animation timelines is created"); + ({inspector, panel, controller}) = yield closeAnimationInspectorAndRestartWithNewUI(); + let timeline = panel.animationsTimelineComponent; + + info("Selecting the test animated node again"); + yield selectNode(".multi", inspector); + + is(controller.animationPlayers.length, + timeline.animationsEl.querySelectorAll(".animation").length, + "As many timeline elements were created as there are playerFronts"); }); diff --git a/browser/devtools/animationinspector/test/browser_animation_shows_player_on_valid_node.js b/browser/devtools/animationinspector/test/browser_animation_shows_player_on_valid_node.js index 3227623d2e9..f92a5a3857e 100644 --- a/browser/devtools/animationinspector/test/browser_animation_shows_player_on_valid_node.js +++ b/browser/devtools/animationinspector/test/browser_animation_shows_player_on_valid_node.js @@ -9,12 +9,18 @@ add_task(function*() { yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); - let {inspector, panel} = yield openAnimationInspector(); + let {inspector, panel} = yield openAnimationInspector(); + yield testShowsAnimations(inspector, panel); + + ({inspector, panel}) = yield closeAnimationInspectorAndRestartWithNewUI(); + yield testShowsAnimations(inspector, panel); +}); + +function* testShowsAnimations(inspector, panel) { info("Select node .animated and check that the panel is not empty"); let node = yield getNodeFront(".animated", inspector); yield selectNode(node, inspector); - is(panel.playerWidgets.length, 1, - "Exactly 1 player widget is shown for animated node"); -}); + assertAnimationsDisplayed(panel, 1); +} diff --git a/browser/devtools/animationinspector/test/browser_animation_target_highlight_select.js b/browser/devtools/animationinspector/test/browser_animation_target_highlight_select.js index 9660ea612bd..0759496b385 100644 --- a/browser/devtools/animationinspector/test/browser_animation_target_highlight_select.js +++ b/browser/devtools/animationinspector/test/browser_animation_target_highlight_select.js @@ -9,14 +9,26 @@ add_task(function*() { yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); - let {toolbox, inspector, panel} = yield openAnimationInspector(); + let ui = yield openAnimationInspector(); + yield testTargetNode(ui); + + ui = yield closeAnimationInspectorAndRestartWithNewUI(); + yield testTargetNode(ui, true); +}); + +function* testTargetNode({toolbox, inspector, panel}, isNewUI) { info("Select the simple animated node"); yield selectNode(".animated", inspector); // Make sure to wait for the target-retrieved event if the nodeFront hasn't // yet been retrieved by the TargetNodeComponent. - let targetNodeComponent = panel.playerWidgets[0].targetNodeComponent; + let targetNodeComponent; + if (isNewUI) { + targetNodeComponent = panel.animationsTimelineComponent.targetNodes[0]; + } else { + targetNodeComponent = panel.playerWidgets[0].targetNodeComponent; + } if (!targetNodeComponent.nodeFront) { yield targetNodeComponent.once("target-retrieved"); } @@ -33,21 +45,29 @@ add_task(function*() { ok(true, "The node-highlight event was fired"); is(targetNodeComponent.nodeFront, nodeFront, "The highlighted node is the one stored on the animation widget"); - is(nodeFront.tagName, "DIV", "The highlighted node has the correct tagName"); - is(nodeFront.attributes[0].name, "class", "The highlighted node has the correct attributes"); - is(nodeFront.attributes[0].value, "ball animated", "The highlighted node has the correct class"); + is(nodeFront.tagName, "DIV", + "The highlighted node has the correct tagName"); + is(nodeFront.attributes[0].name, "class", + "The highlighted node has the correct attributes"); + is(nodeFront.attributes[0].value, "ball animated", + "The highlighted node has the correct class"); info("Select the body node in order to have the list of all animations"); yield selectNode("body", inspector); // Make sure to wait for the target-retrieved event if the nodeFront hasn't // yet been retrieved by the TargetNodeComponent. - targetNodeComponent = panel.playerWidgets[0].targetNodeComponent; + if (isNewUI) { + targetNodeComponent = panel.animationsTimelineComponent.targetNodes[0]; + } else { + targetNodeComponent = panel.playerWidgets[0].targetNodeComponent; + } if (!targetNodeComponent.nodeFront) { yield targetNodeComponent.once("target-retrieved"); } - info("Click on the first animation widget's selector icon and wait for the selection to change"); + info("Click on the first animation widget's selector icon and wait for the " + + "selection to change"); let onSelection = inspector.selection.once("new-node-front"); let onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT); let selectIconEl = targetNodeComponent.selectNodeEl; @@ -59,4 +79,4 @@ add_task(function*() { "The selected node is the one stored on the animation widget"); yield onPanelUpdated; -}); +} diff --git a/browser/devtools/animationinspector/test/browser_animation_toggle_button_toggles_animations.js b/browser/devtools/animationinspector/test/browser_animation_toggle_button_toggles_animations.js index 5707806bb9a..d46afd7ebca 100644 --- a/browser/devtools/animationinspector/test/browser_animation_toggle_button_toggles_animations.js +++ b/browser/devtools/animationinspector/test/browser_animation_toggle_button_toggles_animations.js @@ -11,7 +11,7 @@ add_task(function*() { yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); - let {inspector, panel} = yield openAnimationInspector(); + let {panel} = yield openAnimationInspector(); info("Click the toggle button"); yield panel.toggleAll(); diff --git a/browser/devtools/animationinspector/test/browser_animation_toolbar_exists.js b/browser/devtools/animationinspector/test/browser_animation_toolbar_exists.js index fa51cfbe41f..9257f322258 100644 --- a/browser/devtools/animationinspector/test/browser_animation_toolbar_exists.js +++ b/browser/devtools/animationinspector/test/browser_animation_toolbar_exists.js @@ -9,7 +9,7 @@ add_task(function*() { yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); - let {inspector, panel, window} = yield openAnimationInspector(); + let {inspector, window} = yield openAnimationInspector(); let doc = window.document; let toolbar = doc.querySelector("#toolbar"); diff --git a/browser/devtools/animationinspector/test/browser_animation_ui_updates_when_animation_data_changes.js b/browser/devtools/animationinspector/test/browser_animation_ui_updates_when_animation_data_changes.js index b6af2e4d737..6e4bbb8dbe7 100644 --- a/browser/devtools/animationinspector/test/browser_animation_ui_updates_when_animation_data_changes.js +++ b/browser/devtools/animationinspector/test/browser_animation_ui_updates_when_animation_data_changes.js @@ -9,36 +9,64 @@ add_task(function*() { yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); - let {panel, inspector} = yield openAnimationInspector(); + let ui = yield openAnimationInspector(); + yield testDataUpdates(ui); + + info("Close the toolbox, reload the tab, and try again with the new UI"); + ui = yield closeAnimationInspectorAndRestartWithNewUI(true); + yield testDataUpdates(ui, true); +}); + +function* testDataUpdates({panel, controller, inspector}, isNewUI=false) { info("Select the test node"); yield selectNode(".animated", inspector); - info("Get the player widget"); - let widget = panel.playerWidgets[0]; + let animation = controller.animationPlayers[0]; + yield setStyle(animation, "animationDuration", "5.5s", isNewUI); + yield setStyle(animation, "animationIterationCount", "300", isNewUI); + yield setStyle(animation, "animationDelay", "45s", isNewUI); - yield setStyle(widget, "animationDuration", "5.5s"); - is(widget.metaDataComponent.durationValue.textContent, "5.50s", - "The widget shows the new duration"); + if (isNewUI) { + let animationsEl = panel.animationsTimelineComponent.animationsEl; + let timeBlockEl = animationsEl.querySelector(".time-block"); - yield setStyle(widget, "animationIterationCount", "300"); - is(widget.metaDataComponent.iterationValue.textContent, "300", - "The widget shows the new iteration count"); + // 45s delay + (300 * 5.5)s duration + let expectedTotalDuration = 1695 * 1000; + let timeRatio = expectedTotalDuration / timeBlockEl.offsetWidth; - yield setStyle(widget, "animationDelay", "45s"); - is(widget.metaDataComponent.delayValue.textContent, "45s", - "The widget shows the new delay"); -}); + // XXX: the nb and size of each iteration cannot be tested easily (displayed + // using a linear-gradient background and capped at 2px wide). They should + // be tested in bug 1173761. + let delayWidth = parseFloat(timeBlockEl.querySelector(".delay").style.width); + is(Math.round(delayWidth * timeRatio), 45 * 1000, + "The timeline has the right delay"); + } else { + let widget = panel.playerWidgets[0]; + is(widget.metaDataComponent.durationValue.textContent, "5.50s", + "The widget shows the new duration"); + is(widget.metaDataComponent.iterationValue.textContent, "300", + "The widget shows the new iteration count"); + is(widget.metaDataComponent.delayValue.textContent, "45s", + "The widget shows the new delay"); + } +} -function* setStyle(widget, name, value) { +function* setStyle(animation, name, value, isNewUI=false) { info("Change the animation style via the content DOM. Setting " + name + " to " + value); + + let onAnimationChanged = once(animation, "changed"); yield executeInContent("devtools:test:setStyle", { selector: ".animated", propertyName: name, propertyValue: value }); + yield onAnimationChanged; - info("Wait for the next state update"); - yield onceNextPlayerRefresh(widget.player); + // If this is the playerWidget-based UI, wait for the auto-refresh event too + // to make sure the UI has updated. + if (!isNewUI) { + yield once(animation, animation.AUTO_REFRESH_EVENT); + } } diff --git a/browser/devtools/animationinspector/test/head.js b/browser/devtools/animationinspector/test/head.js index d8b39c959db..fdc1c0e20d8 100644 --- a/browser/devtools/animationinspector/test/head.js +++ b/browser/devtools/animationinspector/test/head.js @@ -9,7 +9,7 @@ const {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {}); const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}); const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {}); const TargetFactory = devtools.TargetFactory; -const {console} = Components.utils.import("resource://gre/modules/devtools/Console.jsm", {}); +const {console} = Cu.import("resource://gre/modules/devtools/Console.jsm", {}); const {ViewHelpers} = Cu.import("resource:///modules/devtools/ViewHelpers.jsm", {}); // All tests are asynchronous @@ -19,17 +19,20 @@ const TEST_URL_ROOT = "http://example.com/browser/browser/devtools/animationinsp const ROOT_TEST_DIR = getRootDirectory(gTestPath); const FRAME_SCRIPT_URL = ROOT_TEST_DIR + "doc_frame_script.js"; const COMMON_FRAME_SCRIPT_URL = "chrome://browser/content/devtools/frame-script-utils.js"; +const NEW_UI_PREF = "devtools.inspector.animationInspectorV3"; // Auto clean-up when a test ends registerCleanupFunction(function*() { - let target = TargetFactory.forTab(gBrowser.selectedTab); - yield gDevTools.closeToolbox(target); + yield closeAnimationInspector(); while (gBrowser.tabs.length > 1) { gBrowser.removeCurrentTab(); } }); +// Make sure the new UI is off by default. +Services.prefs.setBoolPref(NEW_UI_PREF, false); + // Uncomment this pref to dump all devtools emitted events to the console. // Services.prefs.setBoolPref("devtools.dump.emit", true); @@ -45,6 +48,7 @@ registerCleanupFunction(() => gDevTools.testing = false); registerCleanupFunction(() => { Services.prefs.clearUserPref("devtools.dump.emit"); Services.prefs.clearUserPref("devtools.debugger.log"); + Services.prefs.clearUserPref(NEW_UI_PREF); }); /** @@ -77,6 +81,13 @@ function addTab(url) { return def.promise; } +/** + * Switch ON the new UI pref. + */ +function enableNewUI() { + Services.prefs.setBoolPref(NEW_UI_PREF, true); +} + /** * Reload the current tab location. */ @@ -119,6 +130,25 @@ let selectNode = Task.async(function*(data, inspector, reason="test") { yield updated; }); +/** + * Check if there are the expected number of animations being displayed in the + * panel right now. + * @param {AnimationsPanel} panel + * @param {Number} nbAnimations The expected number of animations. + * @param {String} msg An optional string to be used as the assertion message. + */ +function assertAnimationsDisplayed(panel, nbAnimations, msg="") { + let isNewUI = Services.prefs.getBoolPref(NEW_UI_PREF); + msg = msg || `There are ${nbAnimations} animations in the panel`; + if (isNewUI) { + is(panel.animationsTimelineComponent.animationsEl.childNodes.length, + nbAnimations, msg); + } else { + is(panel.playersEl.querySelectorAll(".player-widget").length, + nbAnimations, msg); + } +} + /** * Takes an Inspector panel that was just created, and waits * for a "inspector-updated" event as well as the animation inspector @@ -131,10 +161,9 @@ let waitForAnimationInspectorReady = Task.async(function*(inspector) { let win = inspector.sidebar.getWindowForTab("animationinspector"); let updated = inspector.once("inspector-updated"); - // In e10s, if we wait for underlying toolbox actors to - // load (by setting gDevTools.testing to true), we miss the "animationinspector-ready" - // event on the sidebar, so check to see if the iframe - // is already loaded. + // In e10s, if we wait for underlying toolbox actors to load (by setting + // gDevTools.testing to true), we miss the "animationinspector-ready" event on + // the sidebar, so check to see if the iframe is already loaded. let tabReady = win.document.readyState === "complete" ? promise.resolve() : inspector.sidebar.once("animationinspector-ready"); @@ -145,7 +174,7 @@ let waitForAnimationInspectorReady = Task.async(function*(inspector) { /** * Open the toolbox, with the inspector tool visible and the animationinspector * sidebar selected. - * @return a promise that resolves when the inspector is ready + * @return a promise that resolves when the inspector is ready. */ let openAnimationInspector = Task.async(function*() { let target = TargetFactory.forTab(gBrowser.selectedTab); @@ -185,6 +214,35 @@ let openAnimationInspector = Task.async(function*() { }; }); +/** + * Close the toolbox. + * @return a promise that resolves when the toolbox has closed. + */ +let closeAnimationInspector = Task.async(function*() { + let target = TargetFactory.forTab(gBrowser.selectedTab); + yield gDevTools.closeToolbox(target); +}); + +/** + * During the time period we migrate from the playerWidgets-based UI to the new + * AnimationTimeline UI, we'll want to run certain tests against both UI. + * This closes the toolbox, switch the new UI pref ON, and opens the toolbox + * again, with the animation inspector panel selected. + * @param {Boolean} reload Optionally reload the page after the toolbox was + * closed and before it is opened again. + * @return a promise that resolves when the animation inspector is ready. + */ +let closeAnimationInspectorAndRestartWithNewUI = Task.async(function*(reload) { + info("Close the toolbox and test again with the new UI"); + yield closeAnimationInspector(); + if (reload) { + yield reloadTab(); + } + enableNewUI(); + return yield openAnimationInspector(); +}); + + /** * Wait for the toolbox frame to receive focus after it loads * @param {Toolbox} toolbox @@ -214,7 +272,7 @@ function hasSideBarTab(inspector, id) { * @param {Object} target An observable object that either supports on/off or * addEventListener/removeEventListener * @param {String} eventName - * @param {Boolean} useCapture Optional, for addEventListener/removeEventListener + * @param {Boolean} useCapture Optional, for add/removeEventListener * @return A promise that resolves when the event has been handled */ function once(target, eventName, useCapture=false) { @@ -278,9 +336,9 @@ function executeInContent(name, data={}, objects={}, expectResponse=true) { mm.sendAsyncMessage(name, data, objects); if (expectResponse) { return waitForContentMessage(name); - } else { - return promise.resolve(); } + + return promise.resolve(); } function onceNextPlayerRefresh(player) { @@ -293,7 +351,9 @@ function onceNextPlayerRefresh(player) { * Simulate a click on the playPause button of a playerWidget. */ let togglePlayPauseButton = Task.async(function*(widget) { - let nextState = widget.player.state.playState === "running" ? "paused" : "running"; + let nextState = widget.player.state.playState === "running" + ? "paused" + : "running"; // Note that instead of simulating a real event here, the callback is just // called. This is better because the callback returns a promise, so we know @@ -344,7 +404,8 @@ let waitForStateCondition = Task.async(function*(player, conditionCheck, desc="" * provided string. * @param {AnimationPlayerFront} player * @param {String} playState The playState to expect. - * @return {Promise} Resolves when the playState has changed to the expected value. + * @return {Promise} Resolves when the playState has changed to the expected + * value. */ function waitForPlayState(player, playState) { return waitForStateCondition(player, state => { diff --git a/browser/devtools/animationinspector/utils.js b/browser/devtools/animationinspector/utils.js new file mode 100644 index 00000000000..137745a45aa --- /dev/null +++ b/browser/devtools/animationinspector/utils.js @@ -0,0 +1,135 @@ +/* -*- 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"; + +// How many times, maximum, can we loop before we find the optimal time +// interval in the timeline graph. +const OPTIMAL_TIME_INTERVAL_MAX_ITERS = 100; +// Background time graduations should be multiple of this number of millis. +const TIME_INTERVAL_MULTIPLE = 10; +const TIME_INTERVAL_SCALES = 3; +// The default minimum spacing between time graduations in px. +const TIME_GRADUATION_MIN_SPACING = 10; +// RGB color for the time interval background. +const TIME_INTERVAL_COLOR = [128, 136, 144]; +const TIME_INTERVAL_OPACITY_MIN = 32; // byte +const TIME_INTERVAL_OPACITY_ADD = 32; // byte + +/** + * DOM node creation helper function. + * @param {Object} Options to customize the node to be created. + * - nodeType {String} Optional, defaults to "div", + * - attributes {Object} Optional attributes object like + * {attrName1:value1, attrName2: value2, ...} + * - parent {DOMNode} Mandatory node to append the newly created node to. + * - textContent {String} Optional text for the node. + * @return {DOMNode} The newly created node. + */ +function createNode(options) { + if (!options.parent) { + throw new Error("Missing parent DOMNode to create new node"); + } + + let type = options.nodeType || "div"; + let node = options.parent.ownerDocument.createElement(type); + + for (let name in options.attributes || {}) { + let value = options.attributes[name]; + node.setAttribute(name, value); + } + + if (options.textContent) { + node.textContent = options.textContent; + } + + options.parent.appendChild(node); + return node; +} + +exports.createNode = createNode; + +/** + * Given a data-scale, draw the background for a graph (vertical lines) into a + * canvas and set that canvas as an image-element with an ID that can be used + * from CSS. + * @param {Document} document The document where the image-element should be set. + * @param {String} id The ID for the image-element. + * @param {Number} graphWidth The width of the graph. + * @param {Number} timeScale How many px is 1ms in the graph. + */ +function drawGraphElementBackground(document, id, graphWidth, timeScale) { + let canvas = document.createElement("canvas"); + let ctx = canvas.getContext("2d"); + + // Set the canvas width (as requested) and height (1px, repeated along the Y + // axis). + canvas.width = graphWidth; + canvas.height = 1; + + // Create the image data array which will receive the pixels. + let imageData = ctx.createImageData(canvas.width, canvas.height); + let pixelArray = imageData.data; + + let buf = new ArrayBuffer(pixelArray.length); + let view8bit = new Uint8ClampedArray(buf); + let view32bit = new Uint32Array(buf); + + // Build new millisecond tick lines... + let [r, g, b] = TIME_INTERVAL_COLOR; + let alphaComponent = TIME_INTERVAL_OPACITY_MIN; + let interval = findOptimalTimeInterval(timeScale); + + // Insert one pixel for each division on each scale. + for (let i = 1; i <= TIME_INTERVAL_SCALES; i++) { + let increment = interval * Math.pow(2, i); + for (let x = 0; x < canvas.width; x += increment) { + let position = x | 0; + view32bit[position] = (alphaComponent << 24) | (b << 16) | (g << 8) | r; + } + alphaComponent += TIME_INTERVAL_OPACITY_ADD; + } + + // Flush the image data and cache the waterfall background. + pixelArray.set(view8bit); + ctx.putImageData(imageData, 0, 0); + document.mozSetImageElement(id, canvas); +} + +exports.drawGraphElementBackground = drawGraphElementBackground; + +/** + * Find the optimal interval between time graduations in the animation timeline + * graph based on a time scale and a minimum spacing. + * @param {Number} timeScale How many px is 1ms in the graph. + * @param {Number} minSpacing The minimum spacing between 2 graduations, + * defaults to TIME_GRADUATION_MIN_SPACING. + * @return {Number} The optional interval, in pixels. + */ +function findOptimalTimeInterval(timeScale, + minSpacing=TIME_GRADUATION_MIN_SPACING) { + let timingStep = TIME_INTERVAL_MULTIPLE; + let maxIters = OPTIMAL_TIME_INTERVAL_MAX_ITERS; + let numIters = 0; + + if (timeScale > minSpacing) { + return timeScale; + } + + while (true) { + let scaledStep = timeScale * timingStep; + if (++numIters > maxIters) { + return scaledStep; + } + if (scaledStep < minSpacing) { + timingStep *= 2; + continue; + } + return scaledStep; + } +} + +exports.findOptimalTimeInterval = findOptimalTimeInterval; diff --git a/browser/locales/en-US/chrome/browser/devtools/animationinspector.properties b/browser/locales/en-US/chrome/browser/devtools/animationinspector.properties index c878e6ac696..6db75d2cbf2 100644 --- a/browser/locales/en-US/chrome/browser/devtools/animationinspector.properties +++ b/browser/locales/en-US/chrome/browser/devtools/animationinspector.properties @@ -52,3 +52,9 @@ player.timeLabel=%Ss # drop-down list items that can be used to change the rate at which the # animation runs (1x being the default, 2x being twice as fast). player.playbackRateLabel=%Sx + +# LOCALIZATION NOTE (timeline.timeGraduationLabel): +# This string is displayed at the top of the animation panel, next to each time +# graduation, to indicate what duration (in milliseconds) this graduation +# corresponds to. +timeline.timeGraduationLabel=%Sms diff --git a/browser/themes/shared/devtools/animationinspector.css b/browser/themes/shared/devtools/animationinspector.css index 6cc0bdbdb17..4764458f7dd 100644 --- a/browser/themes/shared/devtools/animationinspector.css +++ b/browser/themes/shared/devtools/animationinspector.css @@ -1,3 +1,17 @@ +/* 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/. */ + +/* Animation-inspector specific theme variables */ + +.theme-dark { + --even-animation-timeline-background-color: rgba(255,255,255,0.03); +} + +.theme-light { + --even-animation-timeline-background-color: rgba(128,128,128,0.03); +} + html { height: 100%; } @@ -32,6 +46,13 @@ body { min-height: 20px; } +/* The main animations container */ + +#players { + height: calc(100% - 20px); + overflow: auto; +} + /* The error message, shown when an invalid/unanimated element is selected */ #error-message { @@ -44,12 +65,6 @@ body { display: none; } -/* The animation players container */ - -#players { - flex: 1; - overflow: auto; -} /* Element picker and toggle-all buttons */ @@ -99,6 +114,156 @@ body { } } +/* Animation timeline component */ + +.animation-timeline { + height: 100%; + overflow: hidden; + /* The timeline gets its background-image from a canvas element created in + /browser/devtools/animationinspector/utils.js drawGraphElementBackground + thanks to document.mozSetImageElement("time-graduations", canvas) + This is done so that the background can be built dynamically from script */ + background-image: -moz-element(#time-graduations); + background-repeat: repeat-y; + /* The animations are drawn 150px from the left edge so that animated nodes + can be displayed in a sidebar */ + background-position: 150px 0; + display: flex; + flex-direction: column; +} + +.animation-timeline .time-header { + margin-left: 150px; + height: 20px; + overflow: hidden; + position: relative; + border-bottom: 1px solid var(--theme-splitter-color); +} + +.animation-timeline .time-header .time-tick { + position: absolute; + top: 3px; +} + +.animation-timeline .animations { + width: 100%; + overflow-y: auto; + overflow-x: hidden; + margin: 0; + padding: 0; + list-style-type: none; +} + +/* Animation block widgets */ + +.animation-timeline .animation { + margin: 4px 0; + height: 20px; + position: relative; +} + +.animation-timeline .animation:nth-child(2n) { + background-color: var(--even-animation-timeline-background-color); +} + +.animation-timeline .animation .target { + width: 150px; + overflow: hidden; + height: 100%; +} + +.animation-timeline .animation-target { + background-color: transparent; +} + +.animation-timeline .animation .time-block { + position: absolute; + top: 0; + left: 150px; + right: 0; + height: 100%; +} + +/* Animation iterations */ + +.animation-timeline .animation .iterations { + position: relative; + height: 100%; + border: 1px solid var(--theme-highlight-lightorange); + box-sizing: border-box; + background: var(--theme-contrast-background); + /* Iterations are displayed with a repeating linear-gradient which size is + dynamically changed from JS */ + background-image: + linear-gradient(to right, + var(--theme-highlight-lightorange) 0, + var(--theme-highlight-lightorange) 1px, + transparent 1px, + transparent 2px); + background-repeat: repeat-x; + background-position: -1px 0; +} + +.animation-timeline .animation .iterations.infinite { + border-right-width: 0; +} + +.animation-timeline .animation .iterations.infinite::before, +.animation-timeline .animation .iterations.infinite::after { + content: ""; + position: absolute; + top: 0; + right: 0; + width: 0; + height: 0; + border-right: 4px solid var(--theme-body-background); + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; +} + +.animation-timeline .animation .iterations.infinite::after { + bottom: 0; + top: unset; +} + +.animation-timeline .animation .animation-title { + height: 1.5em; + width: 100%; + box-sizing: border-box; + overflow: hidden; +} + +.animation-timeline .animation .delay { + position: absolute; + top: 0; + height: 100%; + background-image: linear-gradient(to bottom, + transparent, + transparent 9px, + var(--theme-highlight-lightorange) 9px, + var(--theme-highlight-lightorange) 11px, + transparent 11px, + transparent); +} + +.animation-timeline .animation .delay::before { + position: absolute; + content: ""; + left: 0; + width: 2px; + height: 8px; + top: 50%; + margin-top: -4px; + background: var(--theme-highlight-lightorange); +} + +.animation-timeline .animation .name { + position: absolute; + z-index: 1; + padding: 2px; + white-space: nowrap; +} + /* Animation target node gutter, contains a preview of the dom node */ .animation-target { @@ -253,4 +418,4 @@ body { width: 50px; border-left: 1px solid var(--theme-splitter-color); background: var(--theme-toolbar-background); -} +} \ No newline at end of file diff --git a/toolkit/devtools/server/actors/animation.js b/toolkit/devtools/server/actors/animation.js index 121df2e4660..71b16ca323b 100644 --- a/toolkit/devtools/server/actors/animation.js +++ b/toolkit/devtools/server/actors/animation.js @@ -5,7 +5,8 @@ "use strict"; /** - * Set of actors that expose the Web Animations API to devtools protocol clients. + * Set of actors that expose the Web Animations API to devtools protocol + * clients. * * The |Animations| actor is the main entry point. It is used to discover * animation players on given nodes. @@ -29,11 +30,15 @@ const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {}); const {Task} = Cu.import("resource://gre/modules/Task.jsm", {}); const {setInterval, clearInterval} = require("sdk/timers"); const protocol = require("devtools/server/protocol"); -const {ActorClass, Actor, FrontClass, Front, Arg, method, RetVal, types} = protocol; +const {ActorClass, Actor, FrontClass, Front, + Arg, method, RetVal, types} = protocol; +// Make sure the nodeActor type is know here. const {NodeActor} = require("devtools/server/actors/inspector"); const events = require("sdk/event/core"); -const PLAYER_DEFAULT_AUTO_REFRESH_TIMEOUT = 500; // ms +// How long (in ms) should we wait before polling again the state of an +// animationPlayer. +const PLAYER_DEFAULT_AUTO_REFRESH_TIMEOUT = 500; /** * The AnimationPlayerActor provides information about a given animation: its @@ -47,6 +52,13 @@ const PLAYER_DEFAULT_AUTO_REFRESH_TIMEOUT = 500; // ms let AnimationPlayerActor = ActorClass({ typeName: "animationplayer", + events: { + "changed": { + type: "changed", + state: Arg(0, "json") + } + }, + /** * @param {AnimationsActor} The main AnimationsActor instance * @param {AnimationPlayer} The player object returned by getAnimationPlayers @@ -58,14 +70,29 @@ let AnimationPlayerActor = ActorClass({ initialize: function(animationsActor, player, playerIndex) { Actor.prototype.initialize.call(this, animationsActor.conn); + this.onAnimationMutation = this.onAnimationMutation.bind(this); + + this.tabActor = animationsActor.tabActor; this.player = player; this.node = player.effect.target; this.playerIndex = playerIndex; - this.styles = this.node.ownerDocument.defaultView.getComputedStyle(this.node); + + let win = this.node.ownerDocument.defaultView; + this.styles = win.getComputedStyle(this.node); + + // Listen to animation mutations on the node to alert the front when the + // current animation changes. + this.observer = new win.MutationObserver(this.onAnimationMutation); + this.observer.observe(this.node, {animations: true}); }, destroy: function() { - this.player = this.node = this.styles = null; + // Only try to disconnect the observer if it's not already dead (i.e. if the + // container view hasn't navigated since). + if (this.observer && !Cu.isDeadWrapper(this.observer)) { + this.observer.disconnect(); + } + this.tabActor = this.player = this.node = this.styles = this.observer = null; Actor.prototype.destroy.call(this); }, @@ -95,14 +122,14 @@ let AnimationPlayerActor = ActorClass({ */ getPlayerIndex: function() { let names = this.styles.animationName; + if (names === "none") { + names = this.styles.transitionProperty; + } - // If no names are found, then it's probably a transition, in which case we - // can't find the actual index, so just trust the playerIndex passed by - // the AnimationsActor at initialization time. - // Note that this may be incorrect if by the time the AnimationPlayerActor - // is initialized, one of the transitions has ended, but it's the best we - // can do for now. - if (!names) { + // If we still don't have a name, let's fall back to the provided index + // which may, by now, be wrong, but it's the best we can do until the waapi + // gives us a way to get duration, delay, ... directly. + if (!names || names === "none") { return this.playerIndex; } @@ -114,7 +141,7 @@ let AnimationPlayerActor = ActorClass({ // If there are several names, retrieve the index of the animation name in // the list. names = names.split(",").map(n => n.trim()); - for (let i = 0; i < names.length; i ++) { + for (let i = 0; i < names.length; i++) { if (names[i] === this.player.effect.name) { return i; } @@ -244,6 +271,27 @@ let AnimationPlayerActor = ActorClass({ } }), + /** + * Executed when the current animation changes, used to emit the new state + * the the front. + */ + onAnimationMutation: function(mutations) { + let hasChanged = false; + for (let {changedAnimations} of mutations) { + if (!changedAnimations.length) { + return; + } + if (changedAnimations.some(animation => animation === this.player)) { + hasChanged = true; + break; + } + } + + if (hasChanged) { + events.emit(this, "changed", this.getCurrentState()); + } + }, + /** * Pause the player. */ @@ -348,9 +396,18 @@ let AnimationPlayerFront = FrontClass(AnimationPlayerActor, { delay: this._form.delay, iterationCount: this._form.iterationCount, isRunningOnCompositor: this._form.isRunningOnCompositor - } + }; }, + /** + * Executed when the AnimationPlayerActor emits a "changed" event. Used to + * update the local knowledge of the state. + */ + onChanged: protocol.preEvent("changed", function(partialState) { + let {state} = this.reconstructState(partialState); + this.state = state; + }), + // About auto-refresh: // // The AnimationPlayerFront is capable of automatically refreshing its state @@ -416,19 +473,28 @@ let AnimationPlayerFront = FrontClass(AnimationPlayerActor, { */ getCurrentState: protocol.custom(function() { this.currentStateHasChanged = false; - return this._getCurrentState().then(data => { - for (let key in this.state) { - if (typeof data[key] === "undefined") { - data[key] = this.state[key]; - } else if (data[key] !== this.state[key]) { - this.currentStateHasChanged = true; - } - } - return data; + return this._getCurrentState().then(partialData => { + let {state, hasChanged} = this.reconstructState(partialData); + this.currentStateHasChanged = hasChanged; + return state; }); }, { impl: "_getCurrentState" }), + + reconstructState: function(data) { + let hasChanged = false; + + for (let key in this.state) { + if (typeof data[key] === "undefined") { + data[key] = this.state[key]; + } else if (data[key] !== this.state[key]) { + hasChanged = true; + } + } + + return {state: data, hasChanged}; + } }); /** @@ -449,7 +515,7 @@ let AnimationsActor = exports.AnimationsActor = ActorClass({ typeName: "animations", events: { - "mutations" : { + "mutations": { type: "mutations", changes: Arg(0, "array:animationMutationChange") } @@ -500,7 +566,7 @@ let AnimationsActor = exports.AnimationsActor = ActorClass({ // No care is taken here to destroy the previously stored actors because it // is assumed that the client is responsible for lifetimes of actors. this.actors = []; - for (let i = 0; i < animations.length; i ++) { + for (let i = 0; i < animations.length; i++) { // XXX: for now the index is passed along as the AnimationPlayerActor uses // it to retrieve animation information from CSS. let actor = AnimationPlayerActor(this, animations[i], i); @@ -532,7 +598,7 @@ let AnimationsActor = exports.AnimationsActor = ActorClass({ onAnimationMutation: function(mutations) { let eventData = []; - for (let {addedAnimations, changedAnimations, removedAnimations} of mutations) { + for (let {addedAnimations, removedAnimations} of mutations) { for (let player of removedAnimations) { // Note that animations are reported as removed either when they are // actually removed from the node (e.g. css class removed) or when they @@ -588,9 +654,9 @@ let AnimationsActor = exports.AnimationsActor = ActorClass({ }, /** - * After the client has called getAnimationPlayersForNode for a given DOM node, - * the actor starts sending animation mutations for this node. If the client - * doesn't want this to happen anymore, it should call this method. + * After the client has called getAnimationPlayersForNode for a given DOM + * node, the actor starts sending animation mutations for this node. If the + * client doesn't want this to happen anymore, it should call this method. */ stopAnimationPlayerUpdates: method(function() { if (this.observer && !Cu.isDeadWrapper(this.observer)) { @@ -666,7 +732,7 @@ let AnimationsActor = exports.AnimationsActor = ActorClass({ /** * Play all animations in the current tabActor's frames. - * This method only returns when the animations have left their pending states. + * This method only returns when animations have left their pending states. */ playAll: method(function() { let readyPromises = []; @@ -687,9 +753,8 @@ let AnimationsActor = exports.AnimationsActor = ActorClass({ toggleAll: method(function() { if (this.allAnimationsPaused) { return this.playAll(); - } else { - return this.pauseAll(); } + return this.pauseAll(); }, { request: {}, response: {}