/* 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 { createElement, createFactory, } = require("resource://devtools/client/shared/vendor/react.js"); const { Provider, } = require("resource://devtools/client/shared/vendor/react-redux.js"); const EventEmitter = require("resource://devtools/shared/event-emitter.js"); const App = createFactory( require("resource://devtools/client/inspector/animation/components/App.js") ); const CurrentTimeTimer = require("resource://devtools/client/inspector/animation/current-time-timer.js"); const animationsReducer = require("resource://devtools/client/inspector/animation/reducers/animations.js"); const { updateAnimations, updateDetailVisibility, updateElementPickerEnabled, updateHighlightedNode, updatePlaybackRates, updateSelectedAnimation, updateSidebarSize, } = require("resource://devtools/client/inspector/animation/actions/animations.js"); const { hasAnimationIterationCountInfinite, hasRunningAnimation, } = require("resource://devtools/client/inspector/animation/utils/utils.js"); class AnimationInspector { constructor(inspector, win) { this.inspector = inspector; this.win = win; this.inspector.store.injectReducer("animations", animationsReducer); this.addAnimationsCurrentTimeListener = this.addAnimationsCurrentTimeListener.bind(this); this.getAnimatedPropertyMap = this.getAnimatedPropertyMap.bind(this); this.getAnimationsCurrentTime = this.getAnimationsCurrentTime.bind(this); this.getComputedStyle = this.getComputedStyle.bind(this); this.getNodeFromActor = this.getNodeFromActor.bind(this); this.removeAnimationsCurrentTimeListener = this.removeAnimationsCurrentTimeListener.bind(this); this.rewindAnimationsCurrentTime = this.rewindAnimationsCurrentTime.bind(this); this.selectAnimation = this.selectAnimation.bind(this); this.setAnimationsCurrentTime = this.setAnimationsCurrentTime.bind(this); this.setAnimationsPlaybackRate = this.setAnimationsPlaybackRate.bind(this); this.setAnimationsPlayState = this.setAnimationsPlayState.bind(this); this.setDetailVisibility = this.setDetailVisibility.bind(this); this.setHighlightedNode = this.setHighlightedNode.bind(this); this.setSelectedNode = this.setSelectedNode.bind(this); this.simulateAnimation = this.simulateAnimation.bind(this); this.simulateAnimationForKeyframesProgressBar = this.simulateAnimationForKeyframesProgressBar.bind(this); this.toggleElementPicker = this.toggleElementPicker.bind(this); this.update = this.update.bind(this); this.onAnimationStateChanged = this.onAnimationStateChanged.bind(this); this.onAnimationsCurrentTimeUpdated = this.onAnimationsCurrentTimeUpdated.bind(this); this.onAnimationsMutation = this.onAnimationsMutation.bind(this); this.onCurrentTimeTimerUpdated = this.onCurrentTimeTimerUpdated.bind(this); this.onElementPickerStarted = this.onElementPickerStarted.bind(this); this.onElementPickerStopped = this.onElementPickerStopped.bind(this); this.onNavigate = this.onNavigate.bind(this); this.onSidebarResized = this.onSidebarResized.bind(this); this.onSidebarSelectionChanged = this.onSidebarSelectionChanged.bind(this); this.onTargetAvailable = this.onTargetAvailable.bind(this); EventEmitter.decorate(this); this.emitForTests = this.emitForTests.bind(this); this.initComponents(); this.initListeners(); } initComponents() { const { addAnimationsCurrentTimeListener, emitForTests: emitEventForTest, getAnimatedPropertyMap, getAnimationsCurrentTime, getComputedStyle, getNodeFromActor, isAnimationsRunning, removeAnimationsCurrentTimeListener, rewindAnimationsCurrentTime, selectAnimation, setAnimationsCurrentTime, setAnimationsPlaybackRate, setAnimationsPlayState, setDetailVisibility, setHighlightedNode, setSelectedNode, simulateAnimation, simulateAnimationForKeyframesProgressBar, toggleElementPicker, } = this; const direction = this.win.document.dir; this.animationsCurrentTimeListeners = []; this.isCurrentTimeSet = false; const provider = createElement( Provider, { id: "animationinspector", key: "animationinspector", store: this.inspector.store, }, App({ addAnimationsCurrentTimeListener, direction, emitEventForTest, getAnimatedPropertyMap, getAnimationsCurrentTime, getComputedStyle, getNodeFromActor, isAnimationsRunning, removeAnimationsCurrentTimeListener, rewindAnimationsCurrentTime, selectAnimation, setAnimationsCurrentTime, setAnimationsPlaybackRate, setAnimationsPlayState, setDetailVisibility, setHighlightedNode, setSelectedNode, simulateAnimation, simulateAnimationForKeyframesProgressBar, toggleElementPicker, }) ); this.provider = provider; } async initListeners() { await this.inspector.commands.targetCommand.watchTargets({ types: [this.inspector.commands.targetCommand.TYPES.FRAME], onAvailable: this.onTargetAvailable, }); this.inspector.on("new-root", this.onNavigate); this.inspector.selection.on("new-node-front", this.update); this.inspector.sidebar.on("select", this.onSidebarSelectionChanged); this.inspector.toolbox.on("select", this.onSidebarSelectionChanged); this.inspector.toolbox.on( "inspector-sidebar-resized", this.onSidebarResized ); this.inspector.toolbox.nodePicker.on( "picker-started", this.onElementPickerStarted ); this.inspector.toolbox.nodePicker.on( "picker-stopped", this.onElementPickerStopped ); } destroy() { this.setAnimationStateChangedListenerEnabled(false); this.inspector.off("new-root", this.onNavigate); this.inspector.selection.off("new-node-front", this.update); this.inspector.sidebar.off("select", this.onSidebarSelectionChanged); this.inspector.toolbox.off( "inspector-sidebar-resized", this.onSidebarResized ); this.inspector.toolbox.nodePicker.off( "picker-started", this.onElementPickerStarted ); this.inspector.toolbox.nodePicker.off( "picker-stopped", this.onElementPickerStopped ); this.inspector.toolbox.off("select", this.onSidebarSelectionChanged); if (this.animationsFront) { this.animationsFront.off("mutations", this.onAnimationsMutation); } if (this.simulatedAnimation) { this.simulatedAnimation.cancel(); this.simulatedAnimation = null; } if (this.simulatedElement) { this.simulatedElement.remove(); this.simulatedElement = null; } if (this.simulatedAnimationForKeyframesProgressBar) { this.simulatedAnimationForKeyframesProgressBar.cancel(); this.simulatedAnimationForKeyframesProgressBar = null; } this.stopAnimationsCurrentTimeTimer(); this.inspector = null; this.win = null; } get state() { return this.inspector.store.getState().animations; } addAnimationsCurrentTimeListener(listener) { this.animationsCurrentTimeListeners.push(listener); } /** * This function calls AnimationsFront.setCurrentTimes with considering the createdTime. * * @param {Number} currentTime */ async doSetCurrentTimes(currentTime) { const { animations, timeScale } = this.state; currentTime = currentTime + timeScale.minStartTime; await this.animationsFront.setCurrentTimes(animations, currentTime, true, { relativeToCreatedTime: true, }); } /** * Return a map of animated property from given animation actor. * * @param {Object} animation * @return {Map} A map of animated property * key: {String} Animated property name * value: {Array} Array of keyframe object * Also, the keyframe object is consisted as following. * { * value: {String} style, * offset: {Number} offset of keyframe, * easing: {String} easing from this keyframe to next keyframe, * distance: {Number} use as y coordinate in graph, * } */ getAnimatedPropertyMap(animation) { const properties = animation.state.properties; const animatedPropertyMap = new Map(); for (const { name, values } of properties) { const keyframes = values.map( ({ value, offset, easing, distance = 0 }) => { offset = parseFloat(offset.toFixed(3)); return { value, offset, easing, distance }; } ); animatedPropertyMap.set(name, keyframes); } return animatedPropertyMap; } getAnimationsCurrentTime() { return this.currentTime; } /** * Return the computed style of the specified property after setting the given styles * to the simulated element. * * @param {String} property * CSS property name (e.g. text-align). * @param {Object} styles * Map of CSS property name and value. * @return {String} * Computed style of property. */ getComputedStyle(property, styles) { this.simulatedElement.style.cssText = ""; for (const propertyName in styles) { this.simulatedElement.style.setProperty( propertyName, styles[propertyName] ); } return this.win .getComputedStyle(this.simulatedElement) .getPropertyValue(property); } getNodeFromActor(actorID) { if (!this.inspector) { return Promise.reject("Animation inspector already destroyed"); } return this.inspector.walker.getNodeFromActor(actorID, ["node"]); } isPanelVisible() { return ( this.inspector && this.inspector.toolbox && this.inspector.sidebar && this.inspector.toolbox.currentToolId === "inspector" && this.inspector.sidebar.getCurrentTabID() === "animationinspector" ); } onAnimationStateChanged() { // Simply update the animations since the state has already been updated. this.fireUpdateAction([...this.state.animations]); } /** * This method should call when the current time is changed. * Then, dispatches the current time to listeners that are registered * by addAnimationsCurrentTimeListener. * * @param {Number} currentTime */ onAnimationsCurrentTimeUpdated(currentTime) { this.currentTime = currentTime; for (const listener of this.animationsCurrentTimeListeners) { listener(currentTime); } } /** * This method is called when the current time proceed by CurrentTimeTimer. * * @param {Number} currentTime * @param {Bool} shouldStop */ onCurrentTimeTimerUpdated(currentTime, shouldStop) { if (shouldStop) { this.setAnimationsCurrentTime(currentTime, true); } else { this.onAnimationsCurrentTimeUpdated(currentTime); } } async onAnimationsMutation(changes) { let animations = [...this.state.animations]; const addedAnimations = []; for (const { type, player: animation } of changes) { if (type === "added") { if (!animation.state.type) { // This animation was added but removed immediately. continue; } addedAnimations.push(animation); animation.on("changed", this.onAnimationStateChanged); } else if (type === "removed") { const index = animations.indexOf(animation); if (index < 0) { // This animation was added but removed immediately. continue; } animations.splice(index, 1); animation.off("changed", this.onAnimationStateChanged); } } // Update existing other animations as well since the currentTime would be proceeded // sice the scrubber position is related the currentTime. // Also, don't update the state of removed animations since React components // may refer to the same instance still. try { animations = await this.refreshAnimationsState(animations); } catch (_) { console.error(`Updating Animations failed`); return; } this.fireUpdateAction(animations.concat(addedAnimations)); } onElementPickerStarted() { this.inspector.store.dispatch(updateElementPickerEnabled(true)); } onElementPickerStopped() { this.inspector.store.dispatch(updateElementPickerEnabled(false)); } onNavigate() { if (!this.isPanelVisible()) { return; } this.inspector.store.dispatch(updatePlaybackRates()); } async onSidebarSelectionChanged() { const isPanelVisibled = this.isPanelVisible(); if (this.wasPanelVisibled === isPanelVisibled) { // onSidebarSelectionChanged is called some times even same state // from sidebar and toolbar. return; } this.wasPanelVisibled = isPanelVisibled; if (this.isPanelVisible()) { await this.update(); this.onSidebarResized(null, this.inspector.getSidebarSize()); } else { this.stopAnimationsCurrentTimeTimer(); this.setAnimationStateChangedListenerEnabled(false); } } onSidebarResized(size) { if (!this.isPanelVisible()) { return; } this.inspector.store.dispatch(updateSidebarSize(size)); } async onTargetAvailable({ targetFront }) { if (targetFront.isTopLevel) { this.animationsFront = await targetFront.getFront("animations"); this.animationsFront.setWalkerActor(this.inspector.walker); this.animationsFront.on("mutations", this.onAnimationsMutation); await this.update(); } } removeAnimationsCurrentTimeListener(listener) { this.animationsCurrentTimeListeners = this.animationsCurrentTimeListeners.filter(l => l !== listener); } async rewindAnimationsCurrentTime() { const { timeScale } = this.state; await this.setAnimationsCurrentTime(timeScale.zeroPositionTime, true); } selectAnimation(animation) { this.inspector.store.dispatch(updateSelectedAnimation(animation)); } async setSelectedNode(nodeFront) { if (this.inspector.selection.nodeFront === nodeFront) { return; } await this.inspector .getCommonComponentProps() .setSelectedNode(nodeFront, { reason: "animation-panel" }); } async setAnimationsCurrentTime(currentTime, shouldRefresh) { this.stopAnimationsCurrentTimeTimer(); this.onAnimationsCurrentTimeUpdated(currentTime); if (!shouldRefresh && this.isCurrentTimeSet) { return; } let animations = this.state.animations; this.isCurrentTimeSet = true; try { await this.doSetCurrentTimes(currentTime); animations = await this.refreshAnimationsState(animations); } catch (e) { // Expected if we've already been destroyed or other node have been selected // in the meantime. console.error(e); return; } this.isCurrentTimeSet = false; if (shouldRefresh) { this.fireUpdateAction(animations); } } async setAnimationsPlaybackRate(playbackRate) { if (!this.inspector) { return; // Already destroyed or another node selected. } let animations = this.state.animations; // "changed" event on each animation will fire respectively when the playback // rate changed. Since for each occurrence of event, change of UI is urged. // To avoid this, disable the listeners once in order to not capture the event. this.setAnimationStateChangedListenerEnabled(false); try { await this.animationsFront.setPlaybackRates(animations, playbackRate); animations = await this.refreshAnimationsState(animations); } catch (e) { // Expected if we've already been destroyed or another node has been // selected in the meantime. console.error(e); return; } finally { this.setAnimationStateChangedListenerEnabled(true); } if (animations) { await this.fireUpdateAction(animations); } } async setAnimationsPlayState(doPlay) { if (!this.inspector) { return; // Already destroyed or another node selected. } let { animations, timeScale } = this.state; try { if ( doPlay && animations.every( animation => timeScale.getEndTime(animation) <= animation.state.currentTime ) ) { await this.doSetCurrentTimes(timeScale.zeroPositionTime); } if (doPlay) { await this.animationsFront.playSome(animations); } else { await this.animationsFront.pauseSome(animations); } animations = await this.refreshAnimationsState(animations); } catch (e) { // Expected if we've already been destroyed or other node have been selected // in the meantime. console.error(e); return; } await this.fireUpdateAction(animations); } /** * Enable/disable the animation state change listener. * If set true, observe "changed" event on current animations. * Otherwise, quit observing the "changed" event. * * @param {Bool} isEnabled */ setAnimationStateChangedListenerEnabled(isEnabled) { if (!this.inspector) { return; // Already destroyed. } if (isEnabled) { for (const animation of this.state.animations) { animation.on("changed", this.onAnimationStateChanged); } } else { for (const animation of this.state.animations) { animation.off("changed", this.onAnimationStateChanged); } } } setDetailVisibility(isVisible) { this.inspector.store.dispatch(updateDetailVisibility(isVisible)); } /** * Persistently highlight the given node identified with a unique selector. * If no node is provided, hide any persistent highlighter. * * @param {NodeFront} nodeFront */ async setHighlightedNode(nodeFront) { await this.inspector.highlighters.hideHighlighterType( this.inspector.highlighters.TYPES.SELECTOR ); if (nodeFront) { const selector = await nodeFront.getUniqueSelector(); if (!selector) { console.warn( `Couldn't get unique selector for NodeFront: ${nodeFront.actorID}` ); return; } /** * NOTE: Using a Selector Highlighter here because only one Box Model Highlighter * can be visible at a time. The Box Model Highlighter is shown when hovering nodes * which would cause this persistent highlighter to be hidden unexpectedly. * This limitation of one highlighter type a time should be solved by switching * to a highlighter by role approach (Bug 1663443). */ await this.inspector.highlighters.showHighlighterTypeForNode( this.inspector.highlighters.TYPES.SELECTOR, nodeFront, { hideInfoBar: true, hideGuides: true, selector, } ); } this.inspector.store.dispatch(updateHighlightedNode(nodeFront)); } /** * Returns simulatable animation by given parameters. * The returned animation is implementing Animation interface of Web Animation API. * https://drafts.csswg.org/web-animations/#the-animation-interface * * @param {Array} keyframes * e.g. [{ opacity: 0 }, { opacity: 1 }] * @param {Object} effectTiming * e.g. { duration: 1000, fill: "both" } * @param {Boolean} isElementNeeded * true: create animation with an element. * If want to know computed value of the element, turn on. * false: create animation without an element, * If need to know only timing progress. * @return {Animation} * https://drafts.csswg.org/web-animations/#the-animation-interface */ simulateAnimation(keyframes, effectTiming, isElementNeeded) { // Don't simulate animation if the animation inspector is already destroyed. if (!this.win) { return null; } let targetEl = null; if (isElementNeeded) { if (!this.simulatedElement) { this.simulatedElement = this.win.document.createElement("div"); this.win.document.documentElement.appendChild(this.simulatedElement); } else { // Reset styles. this.simulatedElement.style.cssText = ""; } targetEl = this.simulatedElement; } if (!this.simulatedAnimation) { this.simulatedAnimation = new this.win.Animation(); } this.simulatedAnimation.effect = new this.win.KeyframeEffect( targetEl, keyframes, effectTiming ); return this.simulatedAnimation; } /** * Returns a simulatable efect timing animation for the keyframes progress bar. * The returned animation is implementing Animation interface of Web Animation API. * https://drafts.csswg.org/web-animations/#the-animation-interface * * @param {Object} effectTiming * e.g. { duration: 1000, fill: "both" } * @return {Animation} * https://drafts.csswg.org/web-animations/#the-animation-interface */ simulateAnimationForKeyframesProgressBar(effectTiming) { if (!this.simulatedAnimationForKeyframesProgressBar) { this.simulatedAnimationForKeyframesProgressBar = new this.win.Animation(); } this.simulatedAnimationForKeyframesProgressBar.effect = new this.win.KeyframeEffect(null, null, effectTiming); return this.simulatedAnimationForKeyframesProgressBar; } stopAnimationsCurrentTimeTimer() { if (this.currentTimeTimer) { this.currentTimeTimer.destroy(); this.currentTimeTimer = null; } } startAnimationsCurrentTimeTimer() { const timeScale = this.state.timeScale; const shouldStopAfterEndTime = !hasAnimationIterationCountInfinite( this.state.animations ); const currentTimeTimer = new CurrentTimeTimer( timeScale, shouldStopAfterEndTime, this.win, this.onCurrentTimeTimerUpdated ); currentTimeTimer.start(); this.currentTimeTimer = currentTimeTimer; } toggleElementPicker() { this.inspector.toolbox.nodePicker.togglePicker(); } async update() { if (!this.isPanelVisible()) { return; } const done = this.inspector.updating("animationinspector"); const selection = this.inspector.selection; const animations = selection.isConnected() && selection.isElementNode() ? await this.animationsFront.getAnimationPlayersForNode( selection.nodeFront ) : []; this.fireUpdateAction(animations); this.setAnimationStateChangedListenerEnabled(true); done(); } async refreshAnimationsState(animations) { let error = null; const promises = animations.map(animation => { return new Promise(resolve => { animation .refreshState() .catch(e => { error = e; }) .finally(() => { resolve(); }); }); }); await Promise.all(promises); if (error) { throw new Error(error); } // Even when removal animation on inspected document, refreshAnimationsState // might be called before onAnimationsMutation due to the async timing. // Return the animations as result of refreshAnimationsState after getting rid of // the animations since they should not display. return animations.filter(anim => !!anim.state.type); } fireUpdateAction(animations) { // Animation inspector already destroyed if (!this.inspector) { return; } this.stopAnimationsCurrentTimeTimer(); // Although it is not possible to set a delay or end delay of infinity using // the animation API, if the value passed exceeds the limit of our internal // representation of times, it will be treated as infinity. Rather than // adding special case code to represent this very rare case, we simply omit // such animations from the graph. animations = animations.filter( anim => Math.abs(anim.state.delay) !== Infinity && Math.abs(anim.state.endDelay) !== Infinity ); this.inspector.store.dispatch(updateAnimations(animations)); if (hasRunningAnimation(animations)) { this.startAnimationsCurrentTimeTimer(); } else { // Even no running animations, update the current time once // so as to show the state. this.onCurrentTimeTimerUpdated(this.state.timeScale.getCurrentTime()); } } } module.exports = AnimationInspector;