diff options
Diffstat (limited to 'devtools/client/inspector/animation')
146 files changed, 14414 insertions, 0 deletions
diff --git a/devtools/client/inspector/animation/actions/animations.js b/devtools/client/inspector/animation/actions/animations.js new file mode 100644 index 0000000000..3604630441 --- /dev/null +++ b/devtools/client/inspector/animation/actions/animations.js @@ -0,0 +1,86 @@ +/* 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 { + UPDATE_ANIMATIONS, + UPDATE_DETAIL_VISIBILITY, + UPDATE_ELEMENT_PICKER_ENABLED, + UPDATE_HIGHLIGHTED_NODE, + UPDATE_PLAYBACK_RATES, + UPDATE_SELECTED_ANIMATION, + UPDATE_SIDEBAR_SIZE, +} = require("resource://devtools/client/inspector/animation/actions/index.js"); + +module.exports = { + /** + * Update the list of animation in the animation inspector. + */ + updateAnimations(animations) { + return { + type: UPDATE_ANIMATIONS, + animations, + }; + }, + + /** + * Update visibility of detail pane. + */ + updateDetailVisibility(detailVisibility) { + return { + type: UPDATE_DETAIL_VISIBILITY, + detailVisibility, + }; + }, + + /** + * Update the state of element picker in animation inspector. + */ + updateElementPickerEnabled(elementPickerEnabled) { + return { + type: UPDATE_ELEMENT_PICKER_ENABLED, + elementPickerEnabled, + }; + }, + + /** + * Update the highlighted node. + */ + updateHighlightedNode(nodeFront) { + return { + type: UPDATE_HIGHLIGHTED_NODE, + highlightedNode: nodeFront ? nodeFront.actorID : null, + }; + }, + + /** + * Update the playback rates. + */ + updatePlaybackRates() { + return { + type: UPDATE_PLAYBACK_RATES, + }; + }, + + /** + * Update selected animation. + */ + updateSelectedAnimation(selectedAnimation) { + return { + type: UPDATE_SELECTED_ANIMATION, + selectedAnimation, + }; + }, + + /** + * Update the sidebar size. + */ + updateSidebarSize(sidebarSize) { + return { + type: UPDATE_SIDEBAR_SIZE, + sidebarSize, + }; + }, +}; diff --git a/devtools/client/inspector/animation/actions/index.js b/devtools/client/inspector/animation/actions/index.js new file mode 100644 index 0000000000..572071604c --- /dev/null +++ b/devtools/client/inspector/animation/actions/index.js @@ -0,0 +1,33 @@ +/* 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 { createEnum } = require("resource://devtools/client/shared/enum.js"); + +createEnum( + [ + // Update the list of animation. + "UPDATE_ANIMATIONS", + + // Update visibility of detail pane. + "UPDATE_DETAIL_VISIBILITY", + + // Update state of the picker enabled. + "UPDATE_ELEMENT_PICKER_ENABLED", + + // Update highlighted node. + "UPDATE_HIGHLIGHTED_NODE", + + // Update playback rate. + "UPDATE_PLAYBACK_RATES", + + // Update selected animation. + "UPDATE_SELECTED_ANIMATION", + + // Update sidebar size. + "UPDATE_SIDEBAR_SIZE", + ], + module.exports +); diff --git a/devtools/client/inspector/animation/actions/moz.build b/devtools/client/inspector/animation/actions/moz.build new file mode 100644 index 0000000000..f43007dd6e --- /dev/null +++ b/devtools/client/inspector/animation/actions/moz.build @@ -0,0 +1,8 @@ +# 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/. + +DevToolsModules( + "animations.js", + "index.js", +) diff --git a/devtools/client/inspector/animation/animation.js b/devtools/client/inspector/animation/animation.js new file mode 100644 index 0000000000..c0f5d389d0 --- /dev/null +++ b/devtools/client/inspector/animation/animation.js @@ -0,0 +1,802 @@ +/* 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; diff --git a/devtools/client/inspector/animation/components/AnimatedPropertyItem.js b/devtools/client/inspector/animation/components/AnimatedPropertyItem.js new file mode 100644 index 0000000000..4c4ff37254 --- /dev/null +++ b/devtools/client/inspector/animation/components/AnimatedPropertyItem.js @@ -0,0 +1,64 @@ +/* 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 { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const AnimatedPropertyName = createFactory( + require("resource://devtools/client/inspector/animation/components/AnimatedPropertyName.js") +); +const KeyframesGraph = createFactory( + require("resource://devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraph.js") +); + +class AnimatedPropertyItem extends PureComponent { + static get propTypes() { + return { + getComputedStyle: PropTypes.func.isRequired, + isUnchanged: PropTypes.bool.isRequired, + keyframes: PropTypes.array.isRequired, + name: PropTypes.string.isRequired, + simulateAnimation: PropTypes.func.isRequired, + state: PropTypes.object.isRequired, + type: PropTypes.string.isRequired, + }; + } + + render() { + const { + getComputedStyle, + isUnchanged, + keyframes, + name, + simulateAnimation, + state, + type, + } = this.props; + + return dom.li( + { + className: "animated-property-item" + (isUnchanged ? " unchanged" : ""), + }, + AnimatedPropertyName({ + name, + state, + }), + KeyframesGraph({ + getComputedStyle, + keyframes, + name, + simulateAnimation, + type, + }) + ); + } +} + +module.exports = AnimatedPropertyItem; diff --git a/devtools/client/inspector/animation/components/AnimatedPropertyList.js b/devtools/client/inspector/animation/components/AnimatedPropertyList.js new file mode 100644 index 0000000000..54dff15187 --- /dev/null +++ b/devtools/client/inspector/animation/components/AnimatedPropertyList.js @@ -0,0 +1,140 @@ +/* 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 { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const AnimatedPropertyItem = createFactory( + require("resource://devtools/client/inspector/animation/components/AnimatedPropertyItem.js") +); + +class AnimatedPropertyList extends Component { + static get propTypes() { + return { + animation: PropTypes.object.isRequired, + emitEventForTest: PropTypes.func.isRequired, + getAnimatedPropertyMap: PropTypes.func.isRequired, + getComputedStyle: PropTypes.func.isRequired, + simulateAnimation: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { + // Array of object which has the property name, the keyframes, its aniamtion type + // and unchanged flag. + animatedProperties: null, + // To avoid rendering while the state is updating + // since we call an async function in updateState. + isStateUpdating: false, + }; + } + + componentDidMount() { + // No need to set isStateUpdating state since paint sequence is finish here. + this.updateState(this.props.animation); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + this.setState({ isStateUpdating: true }); + this.updateState(nextProps.animation); + } + + shouldComponentUpdate(nextProps, nextState) { + return !nextState.isStateUpdating; + } + + getPropertyState(property) { + const { animation } = this.props; + + for (const propState of animation.state.propertyState) { + if (propState.property === property) { + return propState; + } + } + + return null; + } + + async updateState(animation) { + const { getAnimatedPropertyMap, emitEventForTest } = this.props; + + let propertyMap = null; + let propertyNames = null; + let types = null; + + try { + propertyMap = getAnimatedPropertyMap(animation); + propertyNames = [...propertyMap.keys()]; + types = await animation.getAnimationTypes(propertyNames); + } catch (e) { + // Expected if we've already been destroyed or other node have been selected + // in the meantime. + console.error(e); + return; + } + + const animatedProperties = propertyNames.map(name => { + const keyframes = propertyMap.get(name); + const type = types[name]; + const isUnchanged = keyframes.every( + keyframe => keyframe.value === keyframes[0].value + ); + return { isUnchanged, keyframes, name, type }; + }); + + animatedProperties.sort((a, b) => { + if (a.isUnchanged === b.isUnchanged) { + return a.name > b.name ? 1 : -1; + } + + return a.isUnchanged ? 1 : -1; + }); + + this.setState({ + animatedProperties, + isStateUpdating: false, + }); + + emitEventForTest("animation-keyframes-rendered"); + } + + render() { + const { getComputedStyle, simulateAnimation } = this.props; + const { animatedProperties } = this.state; + + if (!animatedProperties) { + return null; + } + + return dom.ul( + { + className: "animated-property-list", + }, + animatedProperties.map(({ isUnchanged, keyframes, name, type }) => { + const state = this.getPropertyState(name); + return AnimatedPropertyItem({ + getComputedStyle, + isUnchanged, + keyframes, + name, + simulateAnimation, + state, + type, + }); + }) + ); + } +} + +module.exports = AnimatedPropertyList; diff --git a/devtools/client/inspector/animation/components/AnimatedPropertyListContainer.js b/devtools/client/inspector/animation/components/AnimatedPropertyListContainer.js new file mode 100644 index 0000000000..965099b37e --- /dev/null +++ b/devtools/client/inspector/animation/components/AnimatedPropertyListContainer.js @@ -0,0 +1,90 @@ +/* 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 { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const AnimatedPropertyList = createFactory( + require("resource://devtools/client/inspector/animation/components/AnimatedPropertyList.js") +); +const KeyframesProgressBar = createFactory( + require("resource://devtools/client/inspector/animation/components/KeyframesProgressBar.js") +); +const ProgressInspectionPanel = createFactory( + require("resource://devtools/client/inspector/animation/components/ProgressInspectionPanel.js") +); + +const { + getFormatStr, +} = require("resource://devtools/client/inspector/animation/utils/l10n.js"); + +class AnimatedPropertyListContainer extends PureComponent { + static get propTypes() { + return { + addAnimationsCurrentTimeListener: PropTypes.func.isRequired, + animation: PropTypes.object.isRequired, + emitEventForTest: PropTypes.func.isRequired, + getAnimatedPropertyMap: PropTypes.func.isRequired, + getAnimationsCurrentTime: PropTypes.func.isRequired, + getComputedStyle: PropTypes.func.isRequired, + removeAnimationsCurrentTimeListener: PropTypes.func.isRequired, + simulateAnimation: PropTypes.func.isRequired, + simulateAnimationForKeyframesProgressBar: PropTypes.func.isRequired, + timeScale: PropTypes.object.isRequired, + }; + } + + render() { + const { + addAnimationsCurrentTimeListener, + animation, + emitEventForTest, + getAnimatedPropertyMap, + getAnimationsCurrentTime, + getComputedStyle, + removeAnimationsCurrentTimeListener, + simulateAnimation, + simulateAnimationForKeyframesProgressBar, + timeScale, + } = this.props; + + return dom.div( + { + className: `animated-property-list-container ${animation.state.type}`, + }, + ProgressInspectionPanel({ + indicator: KeyframesProgressBar({ + addAnimationsCurrentTimeListener, + animation, + getAnimationsCurrentTime, + removeAnimationsCurrentTimeListener, + simulateAnimationForKeyframesProgressBar, + timeScale, + }), + list: AnimatedPropertyList({ + animation, + emitEventForTest, + getAnimatedPropertyMap, + getComputedStyle, + simulateAnimation, + }), + ticks: [0, 50, 100].map(position => { + const label = getFormatStr( + "detail.propertiesHeader.percentage", + position + ); + return { position, label }; + }), + }) + ); + } +} + +module.exports = AnimatedPropertyListContainer; diff --git a/devtools/client/inspector/animation/components/AnimatedPropertyName.js b/devtools/client/inspector/animation/components/AnimatedPropertyName.js new file mode 100644 index 0000000000..e7a777ee12 --- /dev/null +++ b/devtools/client/inspector/animation/components/AnimatedPropertyName.js @@ -0,0 +1,37 @@ +/* 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 { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +class AnimatedPropertyName extends PureComponent { + static get propTypes() { + return { + name: PropTypes.string.isRequired, + state: PropTypes.oneOfType([null, PropTypes.object]).isRequired, + }; + } + + render() { + const { name, state } = this.props; + + return dom.div( + { + className: + "animated-property-name" + + (state?.runningOnCompositor ? " compositor" : "") + + (state?.warning ? " warning" : ""), + title: state ? state.warning : "", + }, + dom.span({}, name) + ); + } +} + +module.exports = AnimatedPropertyName; diff --git a/devtools/client/inspector/animation/components/AnimationDetailContainer.js b/devtools/client/inspector/animation/components/AnimationDetailContainer.js new file mode 100644 index 0000000000..9a7b4bfd1a --- /dev/null +++ b/devtools/client/inspector/animation/components/AnimationDetailContainer.js @@ -0,0 +1,90 @@ +/* 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 { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const AnimationDetailHeader = createFactory( + require("resource://devtools/client/inspector/animation/components/AnimationDetailHeader.js") +); +const AnimatedPropertyListContainer = createFactory( + require("resource://devtools/client/inspector/animation/components/AnimatedPropertyListContainer.js") +); + +class AnimationDetailContainer extends PureComponent { + static get propTypes() { + return { + addAnimationsCurrentTimeListener: PropTypes.func.isRequired, + animation: PropTypes.object.isRequired, + emitEventForTest: PropTypes.func.isRequired, + getAnimatedPropertyMap: PropTypes.func.isRequired, + getAnimationsCurrentTime: PropTypes.func.isRequired, + getComputedStyle: PropTypes.func.isRequired, + removeAnimationsCurrentTimeListener: PropTypes.func.isRequired, + setDetailVisibility: PropTypes.func.isRequired, + simulateAnimation: PropTypes.func.isRequired, + simulateAnimationForKeyframesProgressBar: PropTypes.func.isRequired, + timeScale: PropTypes.object.isRequired, + }; + } + + render() { + const { + addAnimationsCurrentTimeListener, + animation, + emitEventForTest, + getAnimatedPropertyMap, + getAnimationsCurrentTime, + getComputedStyle, + removeAnimationsCurrentTimeListener, + setDetailVisibility, + simulateAnimation, + simulateAnimationForKeyframesProgressBar, + timeScale, + } = this.props; + + return dom.div( + { + className: "animation-detail-container", + }, + animation + ? AnimationDetailHeader({ + animation, + setDetailVisibility, + }) + : null, + animation + ? AnimatedPropertyListContainer({ + addAnimationsCurrentTimeListener, + animation, + emitEventForTest, + getAnimatedPropertyMap, + getAnimationsCurrentTime, + getComputedStyle, + removeAnimationsCurrentTimeListener, + simulateAnimation, + simulateAnimationForKeyframesProgressBar, + timeScale, + }) + : null + ); + } +} + +const mapStateToProps = state => { + return { + animation: state.animations.selectedAnimation, + }; +}; + +module.exports = connect(mapStateToProps)(AnimationDetailContainer); diff --git a/devtools/client/inspector/animation/components/AnimationDetailHeader.js b/devtools/client/inspector/animation/components/AnimationDetailHeader.js new file mode 100644 index 0000000000..5f3da0acba --- /dev/null +++ b/devtools/client/inspector/animation/components/AnimationDetailHeader.js @@ -0,0 +1,52 @@ +/* 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 { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const { + getFormattedTitle, +} = require("resource://devtools/client/inspector/animation/utils/l10n.js"); + +class AnimationDetailHeader extends PureComponent { + static get propTypes() { + return { + animation: PropTypes.object.isRequired, + setDetailVisibility: PropTypes.func.isRequired, + }; + } + + onClick(event) { + event.stopPropagation(); + const { setDetailVisibility } = this.props; + setDetailVisibility(false); + } + + render() { + const { animation } = this.props; + + return dom.div( + { + className: "animation-detail-header devtools-toolbar", + }, + dom.div( + { + className: "animation-detail-title", + }, + getFormattedTitle(animation.state) + ), + dom.button({ + className: "animation-detail-close-button devtools-button", + onClick: this.onClick.bind(this), + }) + ); + } +} + +module.exports = AnimationDetailHeader; diff --git a/devtools/client/inspector/animation/components/AnimationItem.js b/devtools/client/inspector/animation/components/AnimationItem.js new file mode 100644 index 0000000000..45bb09bac6 --- /dev/null +++ b/devtools/client/inspector/animation/components/AnimationItem.js @@ -0,0 +1,121 @@ +/* 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 { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const AnimationTarget = createFactory( + require("resource://devtools/client/inspector/animation/components/AnimationTarget.js") +); +const SummaryGraph = createFactory( + require("resource://devtools/client/inspector/animation/components/graph/SummaryGraph.js") +); + +class AnimationItem extends Component { + static get propTypes() { + return { + animation: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + getAnimatedPropertyMap: PropTypes.func.isRequired, + getNodeFromActor: PropTypes.func.isRequired, + isDisplayable: PropTypes.bool.isRequired, + selectAnimation: PropTypes.func.isRequired, + selectedAnimation: PropTypes.object.isRequired, + setHighlightedNode: PropTypes.func.isRequired, + setSelectedNode: PropTypes.func.isRequired, + simulateAnimation: PropTypes.func.isRequired, + timeScale: PropTypes.object.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { + isSelected: this.isSelected(props), + }; + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + this.setState({ + isSelected: this.isSelected(nextProps), + }); + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + this.props.isDisplayable !== nextProps.isDisplayable || + this.state.isSelected !== nextState.isSelected || + this.props.animation !== nextProps.animation || + this.props.timeScale !== nextProps.timeScale + ); + } + + isSelected(props) { + return ( + props.selectedAnimation && + props.animation.actorID === props.selectedAnimation.actorID + ); + } + + render() { + const { + animation, + dispatch, + getAnimatedPropertyMap, + getNodeFromActor, + isDisplayable, + selectAnimation, + setHighlightedNode, + setSelectedNode, + simulateAnimation, + timeScale, + } = this.props; + const { isSelected } = this.state; + + return dom.li( + { + className: + `animation-item ${animation.state.type} ` + + (isSelected ? "selected" : ""), + }, + isDisplayable + ? [ + AnimationTarget({ + animation, + dispatch, + getNodeFromActor, + setHighlightedNode, + setSelectedNode, + }), + SummaryGraph({ + animation, + getAnimatedPropertyMap, + selectAnimation, + simulateAnimation, + timeScale, + }), + ] + : null + ); + } +} + +const mapStateToProps = state => { + return { + selectedAnimation: state.animations.selectedAnimation, + }; +}; + +module.exports = connect(mapStateToProps)(AnimationItem); diff --git a/devtools/client/inspector/animation/components/AnimationList.js b/devtools/client/inspector/animation/components/AnimationList.js new file mode 100644 index 0000000000..7d0f1caf3f --- /dev/null +++ b/devtools/client/inspector/animation/components/AnimationList.js @@ -0,0 +1,72 @@ +/* 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 { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const AnimationItem = createFactory( + require("resource://devtools/client/inspector/animation/components/AnimationItem.js") +); + +class AnimationList extends PureComponent { + static get propTypes() { + return { + animations: PropTypes.arrayOf(PropTypes.object).isRequired, + dispatch: PropTypes.func.isRequired, + displayableRange: PropTypes.object.isRequired, + getAnimatedPropertyMap: PropTypes.func.isRequired, + getNodeFromActor: PropTypes.func.isRequired, + selectAnimation: PropTypes.func.isRequired, + setHighlightedNode: PropTypes.func.isRequired, + setSelectedNode: PropTypes.func.isRequired, + simulateAnimation: PropTypes.func.isRequired, + timeScale: PropTypes.object.isRequired, + }; + } + + render() { + const { + animations, + dispatch, + displayableRange, + getAnimatedPropertyMap, + getNodeFromActor, + selectAnimation, + setHighlightedNode, + setSelectedNode, + simulateAnimation, + timeScale, + } = this.props; + + const { startIndex, endIndex } = displayableRange; + + return dom.ul( + { + className: "animation-list", + }, + animations.map((animation, index) => + AnimationItem({ + animation, + dispatch, + getAnimatedPropertyMap, + getNodeFromActor, + isDisplayable: startIndex <= index && index <= endIndex, + selectAnimation, + setHighlightedNode, + setSelectedNode, + simulateAnimation, + timeScale, + }) + ) + ); + } +} + +module.exports = AnimationList; diff --git a/devtools/client/inspector/animation/components/AnimationListContainer.js b/devtools/client/inspector/animation/components/AnimationListContainer.js new file mode 100644 index 0000000000..d97b368f70 --- /dev/null +++ b/devtools/client/inspector/animation/components/AnimationListContainer.js @@ -0,0 +1,224 @@ +/* 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 { + createFactory, + createRef, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const AnimationList = createFactory( + require("resource://devtools/client/inspector/animation/components/AnimationList.js") +); +const CurrentTimeScrubber = createFactory( + require("resource://devtools/client/inspector/animation/components/CurrentTimeScrubber.js") +); +const ProgressInspectionPanel = createFactory( + require("resource://devtools/client/inspector/animation/components/ProgressInspectionPanel.js") +); + +const { + findOptimalTimeInterval, +} = require("resource://devtools/client/inspector/animation/utils/utils.js"); +const { + getStr, +} = require("resource://devtools/client/inspector/animation/utils/l10n.js"); +const { throttle } = require("resource://devtools/shared/throttle.js"); + +// The minimum spacing between 2 time graduation headers in the timeline (px). +const TIME_GRADUATION_MIN_SPACING = 40; + +class AnimationListContainer extends PureComponent { + static get propTypes() { + return { + addAnimationsCurrentTimeListener: PropTypes.func.isRequired, + animations: PropTypes.arrayOf(PropTypes.object).isRequired, + direction: PropTypes.string.isRequired, + dispatch: PropTypes.func.isRequired, + getAnimatedPropertyMap: PropTypes.func.isRequired, + getNodeFromActor: PropTypes.func.isRequired, + removeAnimationsCurrentTimeListener: PropTypes.func.isRequired, + selectAnimation: PropTypes.func.isRequired, + setAnimationsCurrentTime: PropTypes.func.isRequired, + setHighlightedNode: PropTypes.func.isRequired, + setSelectedNode: PropTypes.func.isRequired, + sidebarWidth: PropTypes.number.isRequired, + simulateAnimation: PropTypes.func.isRequired, + timeScale: PropTypes.object.isRequired, + }; + } + + constructor(props) { + super(props); + + this._ref = createRef(); + + this.updateDisplayableRange = throttle( + this.updateDisplayableRange, + 100, + this + ); + + this.state = { + // tick labels and lines on the progress inspection panel + ticks: [], + // Displayable range. + displayableRange: { startIndex: 0, endIndex: 0 }, + }; + } + + componentDidMount() { + this.updateTicks(this.props); + + const current = this._ref.current; + this._inspectionPanelEl = current.querySelector( + ".progress-inspection-panel" + ); + this._inspectionPanelEl.addEventListener("scroll", () => { + this.updateDisplayableRange(); + }); + + this._animationListEl = current.querySelector(".animation-list"); + const resizeObserver = new current.ownerGlobal.ResizeObserver(() => { + this.updateDisplayableRange(); + }); + resizeObserver.observe(this._animationListEl); + + const animationItemEl = current.querySelector(".animation-item"); + this._itemHeight = animationItemEl.offsetHeight; + + this.updateDisplayableRange(); + } + + componentDidUpdate(prevProps) { + const { timeScale, sidebarWidth } = this.props; + + if ( + timeScale.getDuration() !== prevProps.timeScale.getDuration() || + timeScale.zeroPositionTime !== prevProps.timeScale.zeroPositionTime || + sidebarWidth !== prevProps.sidebarWidth + ) { + this.updateTicks(this.props); + } + } + + /** + * Since it takes too much time if we render all of animation graphs, + * we restrict to render the items that are not in displaying area. + * This function calculates the displayable item range. + */ + updateDisplayableRange() { + const count = + Math.floor(this._animationListEl.offsetHeight / this._itemHeight) + 1; + const index = Math.floor( + this._inspectionPanelEl.scrollTop / this._itemHeight + ); + this.setState({ + displayableRange: { startIndex: index, endIndex: index + count }, + }); + } + + updateTicks(props) { + const { animations, timeScale } = props; + const tickLinesEl = this._ref.current.querySelector(".tick-lines"); + const width = tickLinesEl.offsetWidth; + const animationDuration = timeScale.getDuration(); + const minTimeInterval = + (TIME_GRADUATION_MIN_SPACING * animationDuration) / width; + const intervalLength = findOptimalTimeInterval(minTimeInterval); + const intervalWidth = (intervalLength * width) / animationDuration; + const tickCount = parseInt(width / intervalWidth, 10); + const isAllDurationInfinity = animations.every( + animation => animation.state.duration === Infinity + ); + const zeroBasePosition = + width * (timeScale.zeroPositionTime / animationDuration); + const shiftWidth = zeroBasePosition % intervalWidth; + const needToShift = zeroBasePosition !== 0 && shiftWidth !== 0; + + const ticks = []; + // Need to display first graduation since position will be shifted. + if (needToShift) { + const label = timeScale.formatTime(timeScale.distanceToRelativeTime(0)); + ticks.push({ position: 0, label, width: shiftWidth }); + } + + for (let i = 0; i <= tickCount; i++) { + const position = ((i * intervalWidth + shiftWidth) * 100) / width; + const distance = timeScale.distanceToRelativeTime(position); + const label = + isAllDurationInfinity && i === tickCount + ? getStr("player.infiniteTimeLabel") + : timeScale.formatTime(distance); + ticks.push({ position, label, width: intervalWidth }); + } + + this.setState({ ticks }); + } + + render() { + const { + addAnimationsCurrentTimeListener, + animations, + direction, + dispatch, + getAnimatedPropertyMap, + getNodeFromActor, + removeAnimationsCurrentTimeListener, + selectAnimation, + setAnimationsCurrentTime, + setHighlightedNode, + setSelectedNode, + simulateAnimation, + timeScale, + } = this.props; + const { displayableRange, ticks } = this.state; + + return dom.div( + { + className: "animation-list-container", + ref: this._ref, + }, + ProgressInspectionPanel({ + indicator: CurrentTimeScrubber({ + addAnimationsCurrentTimeListener, + direction, + removeAnimationsCurrentTimeListener, + setAnimationsCurrentTime, + timeScale, + }), + list: AnimationList({ + animations, + dispatch, + displayableRange, + getAnimatedPropertyMap, + getNodeFromActor, + selectAnimation, + setHighlightedNode, + setSelectedNode, + simulateAnimation, + timeScale, + }), + ticks, + }) + ); + } +} + +const mapStateToProps = state => { + return { + sidebarWidth: state.animations.sidebarSize + ? state.animations.sidebarSize.width + : 0, + }; +}; + +module.exports = connect(mapStateToProps)(AnimationListContainer); diff --git a/devtools/client/inspector/animation/components/AnimationTarget.js b/devtools/client/inspector/animation/components/AnimationTarget.js new file mode 100644 index 0000000000..2fa392b3f6 --- /dev/null +++ b/devtools/client/inspector/animation/components/AnimationTarget.js @@ -0,0 +1,182 @@ +/* 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 { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + translateNodeFrontToGrip, +} = require("resource://devtools/client/inspector/shared/utils.js"); + +const { + REPS, + MODE, +} = require("resource://devtools/client/shared/components/reps/index.js"); +const { Rep } = REPS; +const ElementNode = REPS.ElementNode; + +const { + getInspectorStr, +} = require("resource://devtools/client/inspector/animation/utils/l10n.js"); + +const { + highlightNode, + unhighlightNode, +} = require("resource://devtools/client/inspector/boxmodel/actions/box-model-highlighter.js"); + +class AnimationTarget extends Component { + static get propTypes() { + return { + animation: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + getNodeFromActor: PropTypes.func.isRequired, + highlightedNode: PropTypes.string.isRequired, + setHighlightedNode: PropTypes.func.isRequired, + setSelectedNode: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { + nodeFront: null, + }; + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillMount() { + this.updateNodeFront(this.props.animation); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + if (this.props.animation.actorID !== nextProps.animation.actorID) { + this.updateNodeFront(nextProps.animation); + } + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + this.state.nodeFront !== nextState.nodeFront || + this.props.highlightedNode !== nextState.highlightedNode + ); + } + + async updateNodeFront(animation) { + const { getNodeFromActor } = this.props; + + // Try and get it from the playerFront directly. + let nodeFront = animation.animationTargetNodeFront; + + // Next, get it from the walkerActor if it wasn't found. + if (!nodeFront) { + try { + nodeFront = await getNodeFromActor(animation.actorID); + } catch (e) { + // If an error occured while getting the nodeFront and if it can't be + // attributed to the panel having been destroyed in the meantime, this + // error needs to be logged and render needs to stop. + console.error(e); + this.setState({ nodeFront: null }); + return; + } + } + + this.setState({ nodeFront }); + } + + async ensureNodeFront() { + if (!this.state.nodeFront.actorID) { + // In case of no actorID, the node front had been destroyed. + // This will occur when the pseudo element was re-generated. + await this.updateNodeFront(this.props.animation); + } + } + + async highlight() { + await this.ensureNodeFront(); + + if (this.state.nodeFront) { + this.props.dispatch( + highlightNode(this.state.nodeFront, { + hideInfoBar: true, + hideGuides: true, + }) + ); + } + } + + async select() { + await this.ensureNodeFront(); + + if (this.state.nodeFront) { + this.props.setSelectedNode(this.state.nodeFront); + } + } + + render() { + const { dispatch, highlightedNode, setHighlightedNode } = this.props; + const { nodeFront } = this.state; + + if (!nodeFront) { + return dom.div({ + className: "animation-target", + }); + } + + const isHighlighted = nodeFront.actorID === highlightedNode; + + return dom.div( + { + className: "animation-target" + (isHighlighted ? " highlighting" : ""), + }, + Rep({ + defaultRep: ElementNode, + mode: MODE.TINY, + inspectIconTitle: getInspectorStr( + "inspector.nodePreview.highlightNodeLabel" + ), + inspectIconClassName: "highlight-node", + object: translateNodeFrontToGrip(nodeFront), + onDOMNodeClick: () => this.select(), + onDOMNodeMouseOut: () => { + if (!isHighlighted) { + dispatch(unhighlightNode()); + } + }, + onDOMNodeMouseOver: () => { + if (!isHighlighted) { + this.highlight(); + } + }, + onInspectIconClick: (_, e) => { + e.stopPropagation(); + + if (!isHighlighted) { + // At first, hide highlighter which was created by onDOMNodeMouseOver. + dispatch(unhighlightNode()); + } + + setHighlightedNode(isHighlighted ? null : nodeFront); + }, + }) + ); + } +} + +const mapStateToProps = state => { + return { + highlightedNode: state.animations.highlightedNode, + }; +}; + +module.exports = connect(mapStateToProps)(AnimationTarget); diff --git a/devtools/client/inspector/animation/components/AnimationToolbar.js b/devtools/client/inspector/animation/components/AnimationToolbar.js new file mode 100644 index 0000000000..9b6b7aadc8 --- /dev/null +++ b/devtools/client/inspector/animation/components/AnimationToolbar.js @@ -0,0 +1,75 @@ +/* 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 { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const CurrentTimeLabel = createFactory( + require("resource://devtools/client/inspector/animation/components/CurrentTimeLabel.js") +); +const PauseResumeButton = createFactory( + require("resource://devtools/client/inspector/animation/components/PauseResumeButton.js") +); +const PlaybackRateSelector = createFactory( + require("resource://devtools/client/inspector/animation/components/PlaybackRateSelector.js") +); +const RewindButton = createFactory( + require("resource://devtools/client/inspector/animation/components/RewindButton.js") +); + +class AnimationToolbar extends PureComponent { + static get propTypes() { + return { + addAnimationsCurrentTimeListener: PropTypes.func.isRequired, + animations: PropTypes.arrayOf(PropTypes.object).isRequired, + removeAnimationsCurrentTimeListener: PropTypes.func.isRequired, + rewindAnimationsCurrentTime: PropTypes.func.isRequired, + setAnimationsPlaybackRate: PropTypes.func.isRequired, + setAnimationsPlayState: PropTypes.func.isRequired, + timeScale: PropTypes.object.isRequired, + }; + } + + render() { + const { + addAnimationsCurrentTimeListener, + animations, + removeAnimationsCurrentTimeListener, + rewindAnimationsCurrentTime, + setAnimationsPlaybackRate, + setAnimationsPlayState, + timeScale, + } = this.props; + + return dom.div( + { + className: "animation-toolbar devtools-toolbar", + }, + RewindButton({ + rewindAnimationsCurrentTime, + }), + PauseResumeButton({ + animations, + setAnimationsPlayState, + }), + PlaybackRateSelector({ + animations, + setAnimationsPlaybackRate, + }), + CurrentTimeLabel({ + addAnimationsCurrentTimeListener, + removeAnimationsCurrentTimeListener, + timeScale, + }) + ); + } +} + +module.exports = AnimationToolbar; diff --git a/devtools/client/inspector/animation/components/App.js b/devtools/client/inspector/animation/components/App.js new file mode 100644 index 0000000000..75a000286c --- /dev/null +++ b/devtools/client/inspector/animation/components/App.js @@ -0,0 +1,164 @@ +/* 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 { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const AnimationDetailContainer = createFactory( + require("resource://devtools/client/inspector/animation/components/AnimationDetailContainer.js") +); +const AnimationListContainer = createFactory( + require("resource://devtools/client/inspector/animation/components/AnimationListContainer.js") +); +const AnimationToolbar = createFactory( + require("resource://devtools/client/inspector/animation/components/AnimationToolbar.js") +); +const NoAnimationPanel = createFactory( + require("resource://devtools/client/inspector/animation/components/NoAnimationPanel.js") +); +const SplitBox = createFactory( + require("resource://devtools/client/shared/components/splitter/SplitBox.js") +); + +class App extends Component { + static get propTypes() { + return { + addAnimationsCurrentTimeListener: PropTypes.func.isRequired, + animations: PropTypes.arrayOf(PropTypes.object).isRequired, + detailVisibility: PropTypes.bool.isRequired, + direction: PropTypes.string.isRequired, + dispatch: PropTypes.func.isRequired, + emitEventForTest: PropTypes.func.isRequired, + getAnimatedPropertyMap: PropTypes.func.isRequired, + getAnimationsCurrentTime: PropTypes.func.isRequired, + getComputedStyle: PropTypes.func.isRequired, + getNodeFromActor: PropTypes.func.isRequired, + removeAnimationsCurrentTimeListener: PropTypes.func.isRequired, + rewindAnimationsCurrentTime: PropTypes.func.isRequired, + selectAnimation: PropTypes.func.isRequired, + setAnimationsCurrentTime: PropTypes.func.isRequired, + setAnimationsPlaybackRate: PropTypes.func.isRequired, + setAnimationsPlayState: PropTypes.func.isRequired, + setDetailVisibility: PropTypes.func.isRequired, + setHighlightedNode: PropTypes.func.isRequired, + setSelectedNode: PropTypes.func.isRequired, + simulateAnimation: PropTypes.func.isRequired, + simulateAnimationForKeyframesProgressBar: PropTypes.func.isRequired, + timeScale: PropTypes.object.isRequired, + toggleElementPicker: PropTypes.func.isRequired, + toggleLockingHighlight: PropTypes.func.isRequired, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + this.props.animations.length !== 0 || nextProps.animations.length !== 0 + ); + } + + render() { + const { + addAnimationsCurrentTimeListener, + animations, + detailVisibility, + direction, + dispatch, + emitEventForTest, + getAnimatedPropertyMap, + getAnimationsCurrentTime, + getComputedStyle, + getNodeFromActor, + removeAnimationsCurrentTimeListener, + rewindAnimationsCurrentTime, + selectAnimation, + setAnimationsCurrentTime, + setAnimationsPlaybackRate, + setAnimationsPlayState, + setDetailVisibility, + setHighlightedNode, + setSelectedNode, + simulateAnimation, + simulateAnimationForKeyframesProgressBar, + timeScale, + toggleElementPicker, + } = this.props; + + return dom.div( + { + id: "animation-container", + className: detailVisibility ? "animation-detail-visible" : "", + tabIndex: -1, + }, + animations.length + ? [ + AnimationToolbar({ + addAnimationsCurrentTimeListener, + animations, + removeAnimationsCurrentTimeListener, + rewindAnimationsCurrentTime, + setAnimationsPlaybackRate, + setAnimationsPlayState, + timeScale, + }), + SplitBox({ + className: "animation-container-splitter", + endPanel: AnimationDetailContainer({ + addAnimationsCurrentTimeListener, + emitEventForTest, + getAnimatedPropertyMap, + getAnimationsCurrentTime, + getComputedStyle, + removeAnimationsCurrentTimeListener, + setDetailVisibility, + simulateAnimation, + simulateAnimationForKeyframesProgressBar, + timeScale, + }), + endPanelControl: true, + initialHeight: "50%", + splitterSize: 1, + minSize: "30px", + startPanel: AnimationListContainer({ + addAnimationsCurrentTimeListener, + animations, + direction, + dispatch, + getAnimatedPropertyMap, + getNodeFromActor, + removeAnimationsCurrentTimeListener, + selectAnimation, + setAnimationsCurrentTime, + setHighlightedNode, + setSelectedNode, + simulateAnimation, + timeScale, + }), + vert: false, + }), + ] + : NoAnimationPanel({ + toggleElementPicker, + }) + ); + } +} + +const mapStateToProps = state => { + return { + animations: state.animations.animations, + detailVisibility: state.animations.detailVisibility, + timeScale: state.animations.timeScale, + }; +}; + +module.exports = connect(mapStateToProps)(App); diff --git a/devtools/client/inspector/animation/components/CurrentTimeLabel.js b/devtools/client/inspector/animation/components/CurrentTimeLabel.js new file mode 100644 index 0000000000..67b2498e8b --- /dev/null +++ b/devtools/client/inspector/animation/components/CurrentTimeLabel.js @@ -0,0 +1,76 @@ +/* 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 { + createRef, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +class CurrentTimeLabel extends PureComponent { + static get propTypes() { + return { + addAnimationsCurrentTimeListener: PropTypes.func.isRequired, + removeAnimationsCurrentTimeListener: PropTypes.func.isRequired, + timeScale: PropTypes.object.isRequired, + }; + } + + constructor(props) { + super(props); + + this._ref = createRef(); + + const { addAnimationsCurrentTimeListener } = props; + this.onCurrentTimeUpdated = this.onCurrentTimeUpdated.bind(this); + + addAnimationsCurrentTimeListener(this.onCurrentTimeUpdated); + } + + componentWillUnmount() { + const { removeAnimationsCurrentTimeListener } = this.props; + removeAnimationsCurrentTimeListener(this.onCurrentTimeUpdated); + } + + onCurrentTimeUpdated(currentTime) { + const { timeScale } = this.props; + const text = formatStopwatchTime(currentTime - timeScale.zeroPositionTime); + // onCurrentTimeUpdated is bound to requestAnimationFrame. + // As to update the component too frequently has performance issue if React controlled, + // update raw component directly. See Bug 1699039. + this._ref.current.textContent = text; + } + + render() { + return dom.label({ className: "current-time-label", ref: this._ref }); + } +} + +/** + * Format a timestamp (in ms) as a mm:ss.mmm string. + * + * @param {Number} time + * @return {String} + */ +function formatStopwatchTime(time) { + // Format falsy values as 0 + if (!time) { + return "00:00.000"; + } + + const sign = time < 0 ? "-" : ""; + let milliseconds = parseInt(Math.abs(time % 1000), 10); + let seconds = parseInt(Math.abs(time / 1000) % 60, 10); + let minutes = parseInt(Math.abs(time / (1000 * 60)), 10); + + minutes = minutes.toString().padStart(2, "0"); + seconds = seconds.toString().padStart(2, "0"); + milliseconds = milliseconds.toString().padStart(3, "0"); + return `${sign}${minutes}:${seconds}.${milliseconds}`; +} + +module.exports = CurrentTimeLabel; diff --git a/devtools/client/inspector/animation/components/CurrentTimeScrubber.js b/devtools/client/inspector/animation/components/CurrentTimeScrubber.js new file mode 100644 index 0000000000..8b87e32eff --- /dev/null +++ b/devtools/client/inspector/animation/components/CurrentTimeScrubber.js @@ -0,0 +1,131 @@ +/* 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 DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); +const { + createRef, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +class CurrentTimeScrubber extends PureComponent { + static get propTypes() { + return { + addAnimationsCurrentTimeListener: PropTypes.func.isRequired, + direction: PropTypes.string.isRequired, + removeAnimationsCurrentTimeListener: PropTypes.func.isRequired, + setAnimationsCurrentTime: PropTypes.func.isRequired, + timeScale: PropTypes.object.isRequired, + }; + } + + constructor(props) { + super(props); + + this._ref = createRef(); + + const { addAnimationsCurrentTimeListener } = props; + this.onCurrentTimeUpdated = this.onCurrentTimeUpdated.bind(this); + this.onMouseDown = this.onMouseDown.bind(this); + this.onMouseMove = this.onMouseMove.bind(this); + this.onMouseUp = this.onMouseUp.bind(this); + + addAnimationsCurrentTimeListener(this.onCurrentTimeUpdated); + } + + componentDidMount() { + const current = this._ref.current; + current.addEventListener("mousedown", this.onMouseDown); + this._scrubber = current.querySelector(".current-time-scrubber"); + } + + componentWillUnmount() { + const { removeAnimationsCurrentTimeListener } = this.props; + removeAnimationsCurrentTimeListener(this.onCurrentTimeUpdated); + } + + onCurrentTimeUpdated(currentTime) { + const { timeScale } = this.props; + + const position = currentTime / timeScale.getDuration(); + // onCurrentTimeUpdated is bound to requestAnimationFrame. + // As to update the component too frequently has performance issue if React controlled, + // update raw component directly. See Bug 1699039. + this._scrubber.style.marginInlineStart = `${position * 100}%`; + } + + onMouseDown(event) { + event.stopPropagation(); + const current = this._ref.current; + this.controllerArea = current.getBoundingClientRect(); + this.listenerTarget = DevToolsUtils.getTopWindow(current.ownerGlobal); + this.listenerTarget.addEventListener("mousemove", this.onMouseMove); + this.listenerTarget.addEventListener("mouseup", this.onMouseUp); + this.decorationTarget = current.closest(".animation-list-container"); + this.decorationTarget.classList.add("active-scrubber"); + + this.updateAnimationsCurrentTime(event.pageX, true); + } + + onMouseMove(event) { + event.stopPropagation(); + this.isMouseMoved = true; + this.updateAnimationsCurrentTime(event.pageX); + } + + onMouseUp(event) { + event.stopPropagation(); + + if (this.isMouseMoved) { + this.updateAnimationsCurrentTime(event.pageX, true); + this.isMouseMoved = null; + } + + this.uninstallListeners(); + } + + uninstallListeners() { + this.listenerTarget.removeEventListener("mousemove", this.onMouseMove); + this.listenerTarget.removeEventListener("mouseup", this.onMouseUp); + this.listenerTarget = null; + this.decorationTarget.classList.remove("active-scrubber"); + this.decorationTarget = null; + this.controllerArea = null; + } + + updateAnimationsCurrentTime(pageX, needRefresh) { + const { direction, setAnimationsCurrentTime, timeScale } = this.props; + + let progressRate = + (pageX - this.controllerArea.x) / this.controllerArea.width; + + if (progressRate < 0.0) { + progressRate = 0.0; + } else if (progressRate > 1.0) { + progressRate = 1.0; + } + + const time = + direction === "ltr" + ? progressRate * timeScale.getDuration() + : (1 - progressRate) * timeScale.getDuration(); + + setAnimationsCurrentTime(time, needRefresh); + } + + render() { + return dom.div( + { + className: "current-time-scrubber-area", + ref: this._ref, + }, + dom.div({ className: "indication-bar current-time-scrubber" }) + ); + } +} + +module.exports = CurrentTimeScrubber; diff --git a/devtools/client/inspector/animation/components/KeyframesProgressBar.js b/devtools/client/inspector/animation/components/KeyframesProgressBar.js new file mode 100644 index 0000000000..b4e9e526de --- /dev/null +++ b/devtools/client/inspector/animation/components/KeyframesProgressBar.js @@ -0,0 +1,108 @@ +/* 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 { + createRef, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +class KeyframesProgressBar extends PureComponent { + static get propTypes() { + return { + addAnimationsCurrentTimeListener: PropTypes.func.isRequired, + animation: PropTypes.object.isRequired, + getAnimationsCurrentTime: PropTypes.func.isRequired, + removeAnimationsCurrentTimeListener: PropTypes.func.isRequired, + simulateAnimationForKeyframesProgressBar: PropTypes.func.isRequired, + timeScale: PropTypes.object.isRequired, + }; + } + + constructor(props) { + super(props); + + this._progressBarRef = createRef(); + + this.onCurrentTimeUpdated = this.onCurrentTimeUpdated.bind(this); + } + + componentDidMount() { + const { addAnimationsCurrentTimeListener } = this.props; + + this.setupAnimation(this.props); + addAnimationsCurrentTimeListener(this.onCurrentTimeUpdated); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { animation, getAnimationsCurrentTime, timeScale } = nextProps; + + this.setupAnimation(nextProps); + this.updateOffset(getAnimationsCurrentTime(), animation, timeScale); + } + + componentWillUnmount() { + const { removeAnimationsCurrentTimeListener } = this.props; + + removeAnimationsCurrentTimeListener(this.onCurrentTimeUpdated); + this.simulatedAnimation = null; + } + + onCurrentTimeUpdated(currentTime) { + const { animation, timeScale } = this.props; + this.updateOffset(currentTime, animation, timeScale); + } + + updateOffset(currentTime, animation, timeScale) { + const { createdTime, playbackRate } = animation.state; + + const time = + (timeScale.minStartTime + currentTime - createdTime) * playbackRate; + + if (isNaN(time)) { + // Setting an invalid currentTime will throw so bail out if time is not a number for + // any reason. + return; + } + + this.simulatedAnimation.currentTime = time; + const position = + this.simulatedAnimation.effect.getComputedTiming().progress; + + // onCurrentTimeUpdated is bound to requestAnimationFrame. + // As to update the component too frequently has performance issue if React controlled, + // update raw component directly. See Bug 1699039. + this._progressBarRef.current.style.marginInlineStart = `${position * 100}%`; + } + + setupAnimation(props) { + const { animation, simulateAnimationForKeyframesProgressBar } = props; + + if (this.simulatedAnimation) { + this.simulatedAnimation.cancel(); + } + + const timing = Object.assign({}, animation.state, { + iterations: animation.state.iterationCount || Infinity, + }); + + this.simulatedAnimation = simulateAnimationForKeyframesProgressBar(timing); + } + + render() { + return dom.div( + { className: "keyframes-progress-bar-area" }, + dom.div({ + className: "indication-bar keyframes-progress-bar", + ref: this._progressBarRef, + }) + ); + } +} + +module.exports = KeyframesProgressBar; diff --git a/devtools/client/inspector/animation/components/NoAnimationPanel.js b/devtools/client/inspector/animation/components/NoAnimationPanel.js new file mode 100644 index 0000000000..ea034e413d --- /dev/null +++ b/devtools/client/inspector/animation/components/NoAnimationPanel.js @@ -0,0 +1,61 @@ +/* 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 { + Component, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); + +const L10N = new LocalizationHelper( + "devtools/client/locales/animationinspector.properties" +); + +class NoAnimationPanel extends Component { + static get propTypes() { + return { + elementPickerEnabled: PropTypes.bool.isRequired, + toggleElementPicker: PropTypes.func.isRequired, + }; + } + + shouldComponentUpdate(nextProps, nextState) { + return this.props.elementPickerEnabled != nextProps.elementPickerEnabled; + } + + render() { + const { elementPickerEnabled, toggleElementPicker } = this.props; + + return dom.div( + { + className: "animation-error-message devtools-sidepanel-no-result", + }, + dom.p(null, L10N.getStr("panel.noAnimation")), + dom.button({ + className: + "animation-element-picker devtools-button" + + (elementPickerEnabled ? " checked" : ""), + "data-standalone": true, + onClick: event => { + event.stopPropagation(); + toggleElementPicker(); + }, + }) + ); + } +} + +const mapStateToProps = state => { + return { + elementPickerEnabled: state.animations.elementPickerEnabled, + }; +}; + +module.exports = connect(mapStateToProps)(NoAnimationPanel); diff --git a/devtools/client/inspector/animation/components/PauseResumeButton.js b/devtools/client/inspector/animation/components/PauseResumeButton.js new file mode 100644 index 0000000000..bcbb597a39 --- /dev/null +++ b/devtools/client/inspector/animation/components/PauseResumeButton.js @@ -0,0 +1,104 @@ +/* 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 { + createRef, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); + +const { + getStr, +} = require("resource://devtools/client/inspector/animation/utils/l10n.js"); +const { + hasRunningAnimation, +} = require("resource://devtools/client/inspector/animation/utils/utils.js"); + +class PauseResumeButton extends PureComponent { + static get propTypes() { + return { + animations: PropTypes.arrayOf(PropTypes.object).isRequired, + setAnimationsPlayState: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + + this.onKeyDown = this.onKeyDown.bind(this); + this.pauseResumeButtonRef = createRef(); + + this.state = { + isRunning: false, + }; + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillMount() { + this.updateState(this.props); + } + + componentDidMount() { + const targetEl = this.getKeyEventTarget(); + targetEl.addEventListener("keydown", this.onKeyDown); + } + + componentDidUpdate() { + this.updateState(); + } + + componentWillUnount() { + const targetEl = this.getKeyEventTarget(); + targetEl.removeEventListener("keydown", this.onKeyDown); + } + + getKeyEventTarget() { + return this.pauseResumeButtonRef.current.closest("#animation-container"); + } + + onToggleAnimationsPlayState(event) { + event.stopPropagation(); + const { setAnimationsPlayState } = this.props; + const { isRunning } = this.state; + + setAnimationsPlayState(!isRunning); + } + + onKeyDown(event) { + // Prevent to the duplicated call from the key listener and click listener. + if ( + event.keyCode === KeyCodes.DOM_VK_SPACE && + event.target !== this.pauseResumeButtonRef.current + ) { + this.onToggleAnimationsPlayState(event); + } + } + + updateState() { + const { animations } = this.props; + const isRunning = hasRunningAnimation(animations); + this.setState({ isRunning }); + } + + render() { + const { isRunning } = this.state; + + return dom.button({ + className: + "pause-resume-button devtools-button" + (isRunning ? "" : " paused"), + onClick: this.onToggleAnimationsPlayState.bind(this), + title: isRunning + ? getStr("timeline.resumedButtonTooltip") + : getStr("timeline.pausedButtonTooltip"), + ref: this.pauseResumeButtonRef, + }); + } +} + +module.exports = PauseResumeButton; diff --git a/devtools/client/inspector/animation/components/PlaybackRateSelector.js b/devtools/client/inspector/animation/components/PlaybackRateSelector.js new file mode 100644 index 0000000000..2d0de53a0c --- /dev/null +++ b/devtools/client/inspector/animation/components/PlaybackRateSelector.js @@ -0,0 +1,108 @@ +/* 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 { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const { + getFormatStr, +} = require("resource://devtools/client/inspector/animation/utils/l10n.js"); + +const PLAYBACK_RATES = [0.1, 0.25, 0.5, 1, 2, 5, 10]; + +class PlaybackRateSelector extends PureComponent { + static get propTypes() { + return { + animations: PropTypes.arrayOf(PropTypes.object).isRequired, + playbackRates: PropTypes.arrayOf(PropTypes.number).isRequired, + setAnimationsPlaybackRate: PropTypes.func.isRequired, + }; + } + + static getDerivedStateFromProps(props, state) { + const { animations, playbackRates } = props; + + const currentPlaybackRates = sortAndUnique( + animations.map(a => a.state.playbackRate) + ); + const options = sortAndUnique([ + ...PLAYBACK_RATES, + ...playbackRates, + ...currentPlaybackRates, + ]); + + if (currentPlaybackRates.length === 1) { + return { + options, + selected: currentPlaybackRates[0], + }; + } + + // When the animations displayed have mixed playback rates, we can't + // select any of the predefined ones. + return { + options: ["", ...options], + selected: "", + }; + } + + constructor(props) { + super(props); + + this.state = { + options: [], + selected: 1, + }; + } + + onChange(e) { + const { setAnimationsPlaybackRate } = this.props; + + if (!e.target.value) { + return; + } + + setAnimationsPlaybackRate(e.target.value); + } + + render() { + const { options, selected } = this.state; + + return dom.select( + { + className: "playback-rate-selector devtools-button", + onChange: this.onChange.bind(this), + }, + options.map(rate => { + return dom.option( + { + selected: rate === selected ? "true" : null, + value: rate, + }, + rate ? getFormatStr("player.playbackRateLabel", rate) : "-" + ); + }) + ); + } +} + +function sortAndUnique(array) { + return [...new Set(array)].sort((a, b) => a > b); +} + +const mapStateToProps = state => { + return { + playbackRates: state.animations.playbackRates, + }; +}; + +module.exports = connect(mapStateToProps)(PlaybackRateSelector); diff --git a/devtools/client/inspector/animation/components/ProgressInspectionPanel.js b/devtools/client/inspector/animation/components/ProgressInspectionPanel.js new file mode 100644 index 0000000000..71c82dd17b --- /dev/null +++ b/devtools/client/inspector/animation/components/ProgressInspectionPanel.js @@ -0,0 +1,49 @@ +/* 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 { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const TickLabels = createFactory( + require("resource://devtools/client/inspector/animation/components/TickLabels.js") +); +const TickLines = createFactory( + require("resource://devtools/client/inspector/animation/components/TickLines.js") +); + +/** + * This component is a panel for the progress of animations or keyframes, supports + * displaying the ticks, take the areas of indicator and the list. + */ +class ProgressInspectionPanel extends PureComponent { + static get propTypes() { + return { + indicator: PropTypes.any.isRequired, + list: PropTypes.any.isRequired, + ticks: PropTypes.arrayOf(PropTypes.object).isRequired, + }; + } + + render() { + const { indicator, list, ticks } = this.props; + + return dom.div( + { + className: "progress-inspection-panel", + }, + dom.div({ className: "background" }, TickLines({ ticks })), + dom.div({ className: "indicator" }, indicator), + dom.div({ className: "header devtools-toolbar" }, TickLabels({ ticks })), + list + ); + } +} + +module.exports = ProgressInspectionPanel; diff --git a/devtools/client/inspector/animation/components/RewindButton.js b/devtools/client/inspector/animation/components/RewindButton.js new file mode 100644 index 0000000000..f069b42368 --- /dev/null +++ b/devtools/client/inspector/animation/components/RewindButton.js @@ -0,0 +1,38 @@ +/* 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 { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const { + getStr, +} = require("resource://devtools/client/inspector/animation/utils/l10n.js"); + +class RewindButton extends PureComponent { + static get propTypes() { + return { + rewindAnimationsCurrentTime: PropTypes.func.isRequired, + }; + } + + render() { + const { rewindAnimationsCurrentTime } = this.props; + + return dom.button({ + className: "rewind-button devtools-button", + onClick: event => { + event.stopPropagation(); + rewindAnimationsCurrentTime(); + }, + title: getStr("timeline.rewindButtonTooltip"), + }); + } +} + +module.exports = RewindButton; diff --git a/devtools/client/inspector/animation/components/TickLabels.js b/devtools/client/inspector/animation/components/TickLabels.js new file mode 100644 index 0000000000..019c7b177b --- /dev/null +++ b/devtools/client/inspector/animation/components/TickLabels.js @@ -0,0 +1,46 @@ +/* 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 { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +/** + * This component is intended to show tick labels on the header. + */ +class TickLabels extends PureComponent { + static get propTypes() { + return { + ticks: PropTypes.array.isRequired, + }; + } + + render() { + const { ticks } = this.props; + + return dom.div( + { + className: "tick-labels", + }, + ticks.map(tick => + dom.div( + { + className: "tick-label", + style: { + marginInlineStart: `${tick.position}%`, + maxWidth: `${tick.width}px`, + }, + }, + tick.label + ) + ) + ); + } +} + +module.exports = TickLabels; diff --git a/devtools/client/inspector/animation/components/TickLines.js b/devtools/client/inspector/animation/components/TickLines.js new file mode 100644 index 0000000000..d3cbdead98 --- /dev/null +++ b/devtools/client/inspector/animation/components/TickLines.js @@ -0,0 +1,40 @@ +/* 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 { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +/** + * This component is intended to show the tick line as the background. + */ +class TickLines extends PureComponent { + static get propTypes() { + return { + ticks: PropTypes.array.isRequired, + }; + } + + render() { + const { ticks } = this.props; + + return dom.div( + { + className: "tick-lines", + }, + ticks.map(tick => + dom.div({ + className: "tick-line", + style: { marginInlineStart: `${tick.position}%` }, + }) + ) + ); + } +} + +module.exports = TickLines; diff --git a/devtools/client/inspector/animation/components/graph/AnimationName.js b/devtools/client/inspector/animation/components/graph/AnimationName.js new file mode 100644 index 0000000000..1ef2ebd829 --- /dev/null +++ b/devtools/client/inspector/animation/components/graph/AnimationName.js @@ -0,0 +1,38 @@ +/* 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 { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +class AnimationName extends PureComponent { + static get propTypes() { + return { + animation: PropTypes.object.isRequired, + }; + } + + render() { + const { animation } = this.props; + + return dom.svg( + { + className: "animation-name", + }, + dom.text( + { + y: "50%", + x: "100%", + }, + animation.state.name + ) + ); + } +} + +module.exports = AnimationName; diff --git a/devtools/client/inspector/animation/components/graph/ComputedTimingPath.js b/devtools/client/inspector/animation/components/graph/ComputedTimingPath.js new file mode 100644 index 0000000000..fed25e161e --- /dev/null +++ b/devtools/client/inspector/animation/components/graph/ComputedTimingPath.js @@ -0,0 +1,104 @@ +/* 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 PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const { + createSummaryGraphPathStringFunction, + SummaryGraphHelper, +} = require("resource://devtools/client/inspector/animation/utils/graph-helper.js"); +const TimingPath = require("resource://devtools/client/inspector/animation/components/graph/TimingPath.js"); + +class ComputedTimingPath extends TimingPath { + static get propTypes() { + return { + animation: PropTypes.object.isRequired, + durationPerPixel: PropTypes.number.isRequired, + keyframes: PropTypes.object.isRequired, + offset: PropTypes.number.isRequired, + opacity: PropTypes.number.isRequired, + simulateAnimation: PropTypes.func.isRequired, + totalDuration: PropTypes.number.isRequired, + }; + } + + render() { + const { + animation, + durationPerPixel, + keyframes, + offset, + opacity, + simulateAnimation, + totalDuration, + } = this.props; + + const { state } = animation; + const effectTiming = Object.assign({}, state, { + iterations: state.iterationCount ? state.iterationCount : Infinity, + }); + + // Create new keyframes for opacity as computed style. + // The reason why we use computed value instead of computed timing progress is to + // include the easing in keyframes as well. Although the computed timing progress + // is not affected by the easing in keyframes at all, computed value reflects that. + const frames = keyframes.map(keyframe => { + return { + opacity: keyframe.offset, + offset: keyframe.offset, + easing: keyframe.easing, + }; + }); + + const simulatedAnimation = simulateAnimation(frames, effectTiming, true); + + if (!simulatedAnimation) { + return null; + } + + const simulatedElement = simulatedAnimation.effect.target; + const win = simulatedElement.ownerGlobal; + const endTime = simulatedAnimation.effect.getComputedTiming().endTime; + + // Set the underlying opacity to zero so that if we sample the animation's output + // during the delay phase and it is not filling backwards, we get zero. + simulatedElement.style.opacity = 0; + + const getValueFunc = time => { + if (time < 0) { + return 0; + } + + simulatedAnimation.currentTime = time < endTime ? time : endTime; + return win.getComputedStyle(simulatedElement).opacity; + }; + + const toPathStringFunc = createSummaryGraphPathStringFunction( + endTime, + state.playbackRate + ); + const helper = new SummaryGraphHelper( + state, + keyframes, + totalDuration, + durationPerPixel, + getValueFunc, + toPathStringFunc + ); + + return dom.g( + { + className: "animation-computed-timing-path", + style: { opacity }, + transform: `translate(${offset})`, + }, + super.renderGraph(state, helper) + ); + } +} + +module.exports = ComputedTimingPath; diff --git a/devtools/client/inspector/animation/components/graph/DelaySign.js b/devtools/client/inspector/animation/components/graph/DelaySign.js new file mode 100644 index 0000000000..4e817a9d61 --- /dev/null +++ b/devtools/client/inspector/animation/components/graph/DelaySign.js @@ -0,0 +1,42 @@ +/* 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 { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +class DelaySign extends PureComponent { + static get propTypes() { + return { + animation: PropTypes.object.isRequired, + timeScale: PropTypes.object.isRequired, + }; + } + + render() { + const { animation, timeScale } = this.props; + const { delay, isDelayFilled, startTime } = animation.state.absoluteValues; + + const toPercentage = v => (v / timeScale.getDuration()) * 100; + const offset = toPercentage(startTime - timeScale.minStartTime); + const width = toPercentage(Math.abs(delay)); + + return dom.div({ + className: + "animation-delay-sign" + + (delay < 0 ? " negative" : "") + + (isDelayFilled ? " fill" : ""), + style: { + width: `${width}%`, + marginInlineStart: `${offset}%`, + }, + }); + } +} + +module.exports = DelaySign; diff --git a/devtools/client/inspector/animation/components/graph/EffectTimingPath.js b/devtools/client/inspector/animation/components/graph/EffectTimingPath.js new file mode 100644 index 0000000000..4a984bc61c --- /dev/null +++ b/devtools/client/inspector/animation/components/graph/EffectTimingPath.js @@ -0,0 +1,84 @@ +/* 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 PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const { + createSummaryGraphPathStringFunction, + SummaryGraphHelper, +} = require("resource://devtools/client/inspector/animation/utils/graph-helper.js"); +const TimingPath = require("resource://devtools/client/inspector/animation/components/graph/TimingPath.js"); + +class EffectTimingPath extends TimingPath { + static get propTypes() { + return { + animation: PropTypes.object.isRequired, + durationPerPixel: PropTypes.number.isRequired, + offset: PropTypes.number.isRequired, + simulateAnimation: PropTypes.func.isRequired, + totalDuration: PropTypes.number.isRequired, + }; + } + + render() { + const { + animation, + durationPerPixel, + offset, + simulateAnimation, + totalDuration, + } = this.props; + + const { state } = animation; + const effectTiming = Object.assign({}, state, { + iterations: state.iterationCount ? state.iterationCount : Infinity, + }); + + const simulatedAnimation = simulateAnimation(null, effectTiming, false); + + if (!simulatedAnimation) { + return null; + } + + const endTime = simulatedAnimation.effect.getComputedTiming().endTime; + + const getValueFunc = time => { + if (time < 0) { + return 0; + } + + simulatedAnimation.currentTime = time < endTime ? time : endTime; + return Math.max( + simulatedAnimation.effect.getComputedTiming().progress, + 0 + ); + }; + + const toPathStringFunc = createSummaryGraphPathStringFunction( + endTime, + state.playbackRate + ); + const helper = new SummaryGraphHelper( + state, + null, + totalDuration, + durationPerPixel, + getValueFunc, + toPathStringFunc + ); + + return dom.g( + { + className: "animation-effect-timing-path", + transform: `translate(${offset})`, + }, + super.renderGraph(state, helper) + ); + } +} + +module.exports = EffectTimingPath; diff --git a/devtools/client/inspector/animation/components/graph/EndDelaySign.js b/devtools/client/inspector/animation/components/graph/EndDelaySign.js new file mode 100644 index 0000000000..f843123cbf --- /dev/null +++ b/devtools/client/inspector/animation/components/graph/EndDelaySign.js @@ -0,0 +1,44 @@ +/* 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 { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +class EndDelaySign extends PureComponent { + static get propTypes() { + return { + animation: PropTypes.object.isRequired, + timeScale: PropTypes.object.isRequired, + }; + } + + render() { + const { animation, timeScale } = this.props; + const { endDelay, endTime, isEndDelayFilled } = + animation.state.absoluteValues; + + const toPercentage = v => (v / timeScale.getDuration()) * 100; + const absEndDelay = Math.abs(endDelay); + const offset = toPercentage(endTime - absEndDelay - timeScale.minStartTime); + const width = toPercentage(absEndDelay); + + return dom.div({ + className: + "animation-end-delay-sign" + + (endDelay < 0 ? " negative" : "") + + (isEndDelayFilled ? " fill" : ""), + style: { + width: `${width}%`, + marginInlineStart: `${offset}%`, + }, + }); + } +} + +module.exports = EndDelaySign; diff --git a/devtools/client/inspector/animation/components/graph/NegativeDelayPath.js b/devtools/client/inspector/animation/components/graph/NegativeDelayPath.js new file mode 100644 index 0000000000..0cd87dd320 --- /dev/null +++ b/devtools/client/inspector/animation/components/graph/NegativeDelayPath.js @@ -0,0 +1,27 @@ +/* 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 dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const NegativePath = require("resource://devtools/client/inspector/animation/components/graph/NegativePath.js"); + +class NegativeDelayPath extends NegativePath { + getClassName() { + return "animation-negative-delay-path"; + } + + renderGraph(state, helper) { + const startTime = state.delay; + const endTime = 0; + const segments = helper.createPathSegments(startTime, endTime); + + return dom.path({ + d: helper.toPathString(segments), + }); + } +} + +module.exports = NegativeDelayPath; diff --git a/devtools/client/inspector/animation/components/graph/NegativeEndDelayPath.js b/devtools/client/inspector/animation/components/graph/NegativeEndDelayPath.js new file mode 100644 index 0000000000..88f5f65037 --- /dev/null +++ b/devtools/client/inspector/animation/components/graph/NegativeEndDelayPath.js @@ -0,0 +1,27 @@ +/* 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 dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const NegativePath = require("resource://devtools/client/inspector/animation/components/graph/NegativePath.js"); + +class NegativeEndDelayPath extends NegativePath { + getClassName() { + return "animation-negative-end-delay-path"; + } + + renderGraph(state, helper) { + const endTime = state.delay + state.iterationCount * state.duration; + const startTime = endTime + state.endDelay; + const segments = helper.createPathSegments(startTime, endTime); + + return dom.path({ + d: helper.toPathString(segments), + }); + } +} + +module.exports = NegativeEndDelayPath; diff --git a/devtools/client/inspector/animation/components/graph/NegativePath.js b/devtools/client/inspector/animation/components/graph/NegativePath.js new file mode 100644 index 0000000000..4f1d0af9e6 --- /dev/null +++ b/devtools/client/inspector/animation/components/graph/NegativePath.js @@ -0,0 +1,101 @@ +/* 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 { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const { + createSummaryGraphPathStringFunction, + SummaryGraphHelper, +} = require("resource://devtools/client/inspector/animation/utils/graph-helper.js"); + +class NegativePath extends PureComponent { + static get propTypes() { + return { + animation: PropTypes.object.isRequired, + className: PropTypes.string.isRequired, + durationPerPixel: PropTypes.number.isRequired, + keyframes: PropTypes.object.isRequired, + offset: PropTypes.number.isRequired, + simulateAnimation: PropTypes.func.isRequired, + totalDuration: PropTypes.number.isRequired, + }; + } + + render() { + const { + animation, + durationPerPixel, + keyframes, + offset, + simulateAnimation, + totalDuration, + } = this.props; + + const { state } = animation; + const effectTiming = Object.assign({}, state, { + fill: "both", + iterations: state.iterationCount ? state.iterationCount : Infinity, + }); + + // Create new keyframes for opacity as computed style. + // The reason why we use computed value instead of computed timing progress is to + // include the easing in keyframes as well. Although the computed timing progress + // is not affected by the easing in keyframes at all, computed value reflects that. + const frames = keyframes.map(keyframe => { + return { + opacity: keyframe.offset, + offset: keyframe.offset, + easing: keyframe.easing, + }; + }); + + const simulatedAnimation = simulateAnimation(frames, effectTiming, true); + + if (!simulatedAnimation) { + return null; + } + + const simulatedElement = simulatedAnimation.effect.target; + const win = simulatedElement.ownerGlobal; + const endTime = simulatedAnimation.effect.getComputedTiming().endTime; + + // Set the underlying opacity to zero so that if we sample the animation's output + // during the delay phase and it is not filling backwards, we get zero. + simulatedElement.style.opacity = 0; + + const getValueFunc = time => { + simulatedAnimation.currentTime = time; + return win.getComputedStyle(simulatedElement).opacity; + }; + + const toPathStringFunc = createSummaryGraphPathStringFunction( + endTime, + state.playbackRate + ); + const helper = new SummaryGraphHelper( + state, + keyframes, + totalDuration, + durationPerPixel, + getValueFunc, + toPathStringFunc + ); + + return dom.g( + { + className: this.getClassName(), + transform: `translate(${offset})`, + }, + this.renderGraph(state, helper) + ); + } +} + +module.exports = NegativePath; diff --git a/devtools/client/inspector/animation/components/graph/SummaryGraph.js b/devtools/client/inspector/animation/components/graph/SummaryGraph.js new file mode 100644 index 0000000000..bac54ea26f --- /dev/null +++ b/devtools/client/inspector/animation/components/graph/SummaryGraph.js @@ -0,0 +1,205 @@ +/* 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 { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const AnimationName = createFactory( + require("resource://devtools/client/inspector/animation/components/graph/AnimationName.js") +); +const DelaySign = createFactory( + require("resource://devtools/client/inspector/animation/components/graph/DelaySign.js") +); +const EndDelaySign = createFactory( + require("resource://devtools/client/inspector/animation/components/graph/EndDelaySign.js") +); +const SummaryGraphPath = createFactory( + require("resource://devtools/client/inspector/animation/components/graph/SummaryGraphPath.js") +); + +const { + getFormattedTitle, + getFormatStr, + getStr, + numberWithDecimals, +} = require("resource://devtools/client/inspector/animation/utils/l10n.js"); + +class SummaryGraph extends PureComponent { + static get propTypes() { + return { + animation: PropTypes.object.isRequired, + getAnimatedPropertyMap: PropTypes.func.isRequired, + selectAnimation: PropTypes.func.isRequired, + simulateAnimation: PropTypes.func.isRequired, + timeScale: PropTypes.object.isRequired, + }; + } + + constructor(props) { + super(props); + + this.onClick = this.onClick.bind(this); + } + + onClick(event) { + event.stopPropagation(); + this.props.selectAnimation(this.props.animation); + } + + getTitleText(state) { + const getTime = time => + getFormatStr("player.timeLabel", numberWithDecimals(time / 1000, 2)); + const getTimeOrInfinity = time => + time === Infinity ? getStr("player.infiniteDurationText") : getTime(time); + + let text = ""; + + // Adding the name. + text += getFormattedTitle(state); + text += "\n"; + + // Adding the delay. + if (state.delay) { + text += getStr("player.animationDelayLabel") + " "; + text += getTime(state.delay); + text += "\n"; + } + + // Adding the duration. + text += getStr("player.animationDurationLabel") + " "; + text += getTimeOrInfinity(state.duration); + text += "\n"; + + // Adding the endDelay. + if (state.endDelay) { + text += getStr("player.animationEndDelayLabel") + " "; + text += getTime(state.endDelay); + text += "\n"; + } + + // Adding the iteration count (the infinite symbol, or an integer). + if (state.iterationCount !== 1) { + text += getStr("player.animationIterationCountLabel") + " "; + text += + state.iterationCount || getStr("player.infiniteIterationCountText"); + text += "\n"; + } + + // Adding the iteration start. + if (state.iterationStart !== 0) { + text += getFormatStr( + "player.animationIterationStartLabel2", + state.iterationStart, + getTimeOrInfinity(state.iterationStart * state.duration) + ); + text += "\n"; + } + + // Adding the easing if it is not "linear". + if (state.easing && state.easing !== "linear") { + text += getStr("player.animationOverallEasingLabel") + " "; + text += state.easing; + text += "\n"; + } + + // Adding the fill mode. + if (state.fill && state.fill !== "none") { + text += getStr("player.animationFillLabel") + " "; + text += state.fill; + text += "\n"; + } + + // Adding the direction mode if it is not "normal". + if (state.direction && state.direction !== "normal") { + text += getStr("player.animationDirectionLabel") + " "; + text += state.direction; + text += "\n"; + } + + // Adding the playback rate if it's different than 1. + if (state.playbackRate !== 1) { + text += getStr("player.animationRateLabel") + " "; + text += state.playbackRate; + text += "\n"; + } + + // Adding the animation-timing-function + // if it is not "ease" which is default value for CSS Animations. + if ( + state.animationTimingFunction && + state.animationTimingFunction !== "ease" + ) { + text += getStr("player.animationTimingFunctionLabel") + " "; + text += state.animationTimingFunction; + text += "\n"; + } + + // Adding a note that the animation is running on the compositor thread if + // needed. + if (state.propertyState) { + if ( + state.propertyState.every(propState => propState.runningOnCompositor) + ) { + text += getStr("player.allPropertiesOnCompositorTooltip"); + } else if ( + state.propertyState.some(propState => propState.runningOnCompositor) + ) { + text += getStr("player.somePropertiesOnCompositorTooltip"); + } + } else if (state.isRunningOnCompositor) { + text += getStr("player.runningOnCompositorTooltip"); + } + + return text; + } + + render() { + const { animation, getAnimatedPropertyMap, simulateAnimation, timeScale } = + this.props; + + const { iterationCount } = animation.state; + const { delay, endDelay } = animation.state.absoluteValues; + + return dom.div( + { + className: + "animation-summary-graph" + + (animation.state.isRunningOnCompositor ? " compositor" : ""), + onClick: this.onClick, + title: this.getTitleText(animation.state), + }, + SummaryGraphPath({ + animation, + getAnimatedPropertyMap, + simulateAnimation, + timeScale, + }), + delay + ? DelaySign({ + animation, + timeScale, + }) + : null, + iterationCount && endDelay + ? EndDelaySign({ + animation, + timeScale, + }) + : null, + animation.state.name + ? AnimationName({ + animation, + }) + : null + ); + } +} + +module.exports = SummaryGraph; diff --git a/devtools/client/inspector/animation/components/graph/SummaryGraphPath.js b/devtools/client/inspector/animation/components/graph/SummaryGraphPath.js new file mode 100644 index 0000000000..0183c7413e --- /dev/null +++ b/devtools/client/inspector/animation/components/graph/SummaryGraphPath.js @@ -0,0 +1,282 @@ +/* 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 { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js"); + +const ComputedTimingPath = createFactory( + require("resource://devtools/client/inspector/animation/components/graph/ComputedTimingPath.js") +); +const EffectTimingPath = createFactory( + require("resource://devtools/client/inspector/animation/components/graph/EffectTimingPath.js") +); +const NegativeDelayPath = createFactory( + require("resource://devtools/client/inspector/animation/components/graph/NegativeDelayPath.js") +); +const NegativeEndDelayPath = createFactory( + require("resource://devtools/client/inspector/animation/components/graph/NegativeEndDelayPath.js") +); +const { + DEFAULT_GRAPH_HEIGHT, +} = require("resource://devtools/client/inspector/animation/utils/graph-helper.js"); + +// Minimum opacity for semitransparent fill color for keyframes's easing graph. +const MIN_KEYFRAMES_EASING_OPACITY = 0.5; + +class SummaryGraphPath extends Component { + static get propTypes() { + return { + animation: PropTypes.object.isRequired, + getAnimatedPropertyMap: PropTypes.object.isRequired, + simulateAnimation: PropTypes.func.isRequired, + timeScale: PropTypes.object.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { + // Duration which can display in one pixel. + durationPerPixel: 0, + // To avoid rendering while the state is updating + // since we call an async function in updateState. + isStateUpdating: false, + // List of keyframe which consists by only offset and easing. + keyframesList: [], + }; + } + + componentDidMount() { + // No need to set isStateUpdating state since paint sequence is finish here. + this.updateState(this.props); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + this.setState({ isStateUpdating: true }); + this.updateState(nextProps); + } + + shouldComponentUpdate(nextProps, nextState) { + return !nextState.isStateUpdating; + } + + /** + * Return animatable keyframes list which has only offset and easing. + * Also, this method remove duplicate keyframes. + * For example, if the given animatedPropertyMap is, + * [ + * { + * key: "color", + * values: [ + * { + * offset: 0, + * easing: "ease", + * value: "rgb(255, 0, 0)", + * }, + * { + * offset: 1, + * value: "rgb(0, 255, 0)", + * }, + * ], + * }, + * { + * key: "opacity", + * values: [ + * { + * offset: 0, + * easing: "ease", + * value: 0, + * }, + * { + * offset: 1, + * value: 1, + * }, + * ], + * }, + * ] + * + * then this method returns, + * [ + * [ + * { + * offset: 0, + * easing: "ease", + * }, + * { + * offset: 1, + * }, + * ], + * ] + * + * @param {Map} animated property map + * which can get form getAnimatedPropertyMap in animation.js + * @return {Array} list of keyframes which has only easing and offset. + */ + getOffsetAndEasingOnlyKeyframes(animatedPropertyMap) { + return [...animatedPropertyMap.values()] + .filter((keyframes1, i, self) => { + return ( + i !== + self.findIndex((keyframes2, j) => { + return this.isOffsetAndEasingKeyframesEqual(keyframes1, keyframes2) + ? j + : -1; + }) + ); + }) + .map(keyframes => { + return keyframes.map(keyframe => { + return { easing: keyframe.easing, offset: keyframe.offset }; + }); + }); + } + + /** + * Return true if given keyframes have same length, offset and easing. + * + * @param {Array} keyframes1 + * @param {Array} keyframes2 + * @return {Boolean} true: equals + */ + isOffsetAndEasingKeyframesEqual(keyframes1, keyframes2) { + if (keyframes1.length !== keyframes2.length) { + return false; + } + + for (let i = 0; i < keyframes1.length; i++) { + const keyframe1 = keyframes1[i]; + const keyframe2 = keyframes2[i]; + + if ( + keyframe1.offset !== keyframe2.offset || + keyframe1.easing !== keyframe2.easing + ) { + return false; + } + } + + return true; + } + + updateState(props) { + const { animation, getAnimatedPropertyMap, timeScale } = props; + + let animatedPropertyMap = null; + let thisEl = null; + + try { + animatedPropertyMap = getAnimatedPropertyMap(animation); + thisEl = ReactDOM.findDOMNode(this); + } catch (e) { + // Expected if we've already been destroyed or other node have been selected + // in the meantime. + console.error(e); + return; + } + + const keyframesList = + this.getOffsetAndEasingOnlyKeyframes(animatedPropertyMap); + const totalDuration = + timeScale.getDuration() * Math.abs(animation.state.playbackRate); + const durationPerPixel = totalDuration / thisEl.parentNode.clientWidth; + + this.setState({ + durationPerPixel, + isStateUpdating: false, + keyframesList, + }); + } + + render() { + const { durationPerPixel, keyframesList } = this.state; + const { animation, simulateAnimation, timeScale } = this.props; + + if (!durationPerPixel || !animation.state.type) { + // Undefined animation.state.type means that the animation had been removed already. + // Even if the animation was removed, we still need the empty svg since the + // component might be re-used. + return dom.svg(); + } + + const { playbackRate } = animation.state; + const { createdTime } = animation.state.absoluteValues; + const absPlaybackRate = Math.abs(playbackRate); + + // Absorb the playbackRate in viewBox of SVG and offset of child path elements + // in order to each graph path components can draw without considering to the + // playbackRate. + const offset = createdTime * absPlaybackRate; + const startTime = timeScale.minStartTime * absPlaybackRate; + const totalDuration = timeScale.getDuration() * absPlaybackRate; + const opacity = Math.max( + 1 / keyframesList.length, + MIN_KEYFRAMES_EASING_OPACITY + ); + + return dom.svg( + { + className: "animation-summary-graph-path", + preserveAspectRatio: "none", + viewBox: + `${startTime} -${DEFAULT_GRAPH_HEIGHT} ` + + `${totalDuration} ${DEFAULT_GRAPH_HEIGHT}`, + }, + keyframesList.map(keyframes => + ComputedTimingPath({ + animation, + durationPerPixel, + keyframes, + offset, + opacity, + simulateAnimation, + totalDuration, + }) + ), + animation.state.easing !== "linear" + ? EffectTimingPath({ + animation, + durationPerPixel, + offset, + simulateAnimation, + totalDuration, + }) + : null, + animation.state.delay < 0 + ? keyframesList.map(keyframes => { + return NegativeDelayPath({ + animation, + durationPerPixel, + keyframes, + offset, + simulateAnimation, + totalDuration, + }); + }) + : null, + animation.state.iterationCount && animation.state.endDelay < 0 + ? keyframesList.map(keyframes => { + return NegativeEndDelayPath({ + animation, + durationPerPixel, + keyframes, + offset, + simulateAnimation, + totalDuration, + }); + }) + : null + ); + } +} + +module.exports = SummaryGraphPath; diff --git a/devtools/client/inspector/animation/components/graph/TimingPath.js b/devtools/client/inspector/animation/components/graph/TimingPath.js new file mode 100644 index 0000000000..7949527988 --- /dev/null +++ b/devtools/client/inspector/animation/components/graph/TimingPath.js @@ -0,0 +1,450 @@ +/* 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 { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +// Show max 10 iterations for infinite animations +// to give users a clue that the animation does repeat. +const MAX_INFINITE_ANIMATIONS_ITERATIONS = 10; + +class TimingPath extends PureComponent { + /** + * Render a graph of given parameters and return as <path> element list. + * + * @param {Object} state + * State of animation. + * @param {SummaryGraphHelper} helper + * Instance of SummaryGraphHelper. + * @return {Array} + * list of <path> element. + */ + renderGraph(state, helper) { + // Starting time of main iteration. + let mainIterationStartTime = 0; + let iterationStart = state.iterationStart; + let iterationCount = state.iterationCount ? state.iterationCount : Infinity; + + const pathList = []; + + // Append delay. + if (state.delay > 0) { + this.renderDelay(pathList, state, helper); + mainIterationStartTime = state.delay; + } else { + const negativeDelayCount = -state.delay / state.duration; + // Move to forward the starting point for negative delay. + iterationStart += negativeDelayCount; + // Consume iteration count by negative delay. + if (iterationCount !== Infinity) { + iterationCount -= negativeDelayCount; + } + } + + if (state.duration === Infinity) { + this.renderInfinityDuration( + pathList, + state, + mainIterationStartTime, + helper + ); + return pathList; + } + + // Append 1st section of iterations, + // This section is only useful in cases where iterationStart has decimals. + // e.g. + // if { iterationStart: 0.25, iterations: 3 }, firstSectionCount is 0.75. + const firstSectionCount = + iterationStart % 1 === 0 + ? 0 + : Math.min(1 - (iterationStart % 1), iterationCount); + + if (firstSectionCount) { + this.renderFirstIteration( + pathList, + state, + mainIterationStartTime, + firstSectionCount, + helper + ); + } + + if (iterationCount === Infinity) { + // If the animation repeats infinitely, + // we fill the remaining area with iteration paths. + this.renderInfinity( + pathList, + state, + mainIterationStartTime, + firstSectionCount, + helper + ); + } else { + // Otherwise, we show remaining iterations, endDelay and fill. + + // Append forwards fill-mode. + if (state.fill === "both" || state.fill === "forwards") { + this.renderForwardsFill( + pathList, + state, + mainIterationStartTime, + iterationCount, + helper + ); + } + + // Append middle section of iterations. + // e.g. + // if { iterationStart: 0.25, iterations: 3 }, middleSectionCount is 2. + const middleSectionCount = Math.floor(iterationCount - firstSectionCount); + this.renderMiddleIterations( + pathList, + state, + mainIterationStartTime, + firstSectionCount, + middleSectionCount, + helper + ); + + // Append last section of iterations, if there is remaining iteration. + // e.g. + // if { iterationStart: 0.25, iterations: 3 }, lastSectionCount is 0.25. + const lastSectionCount = + iterationCount - middleSectionCount - firstSectionCount; + if (lastSectionCount) { + this.renderLastIteration( + pathList, + state, + mainIterationStartTime, + firstSectionCount, + middleSectionCount, + lastSectionCount, + helper + ); + } + + // Append endDelay. + if (state.endDelay > 0) { + this.renderEndDelay( + pathList, + state, + mainIterationStartTime, + iterationCount, + helper + ); + } + } + return pathList; + } + + /** + * Render 'delay' part in animation and add a <path> element to given pathList. + * + * @param {Array} pathList + * Add rendered <path> element to this array. + * @param {Object} state + * State of animation. + * @param {SummaryGraphHelper} helper + * Instance of SummaryGraphHelper. + */ + renderDelay(pathList, state, helper) { + const startSegment = helper.getSegment(0); + const endSegment = { x: state.delay, y: startSegment.y }; + const segments = [startSegment, endSegment]; + pathList.push( + dom.path({ + className: "animation-delay-path", + d: helper.toPathString(segments), + }) + ); + } + + /** + * Render 1st section of iterations and add a <path> element to given pathList. + * This section is only useful in cases where iterationStart has decimals. + * + * @param {Array} pathList + * Add rendered <path> element to this array. + * @param {Object} state + * State of animation. + * @param {Number} mainIterationStartTime + * Start time of main iteration. + * @param {Number} firstSectionCount + * Iteration count of first section. + * @param {SummaryGraphHelper} helper + * Instance of SummaryGraphHelper. + */ + renderFirstIteration( + pathList, + state, + mainIterationStartTime, + firstSectionCount, + helper + ) { + const startTime = mainIterationStartTime; + const endTime = startTime + firstSectionCount * state.duration; + const segments = helper.createPathSegments(startTime, endTime); + pathList.push( + dom.path({ + className: "animation-iteration-path", + d: helper.toPathString(segments), + }) + ); + } + + /** + * Render middle iterations and add <path> elements to given pathList. + * + * @param {Array} pathList + * Add rendered <path> elements to this array. + * @param {Object} state + * State of animation. + * @param {Number} mainIterationStartTime + * Starting time of main iteration. + * @param {Number} firstSectionCount + * Iteration count of first section. + * @param {Number} middleSectionCount + * Iteration count of middle section. + * @param {SummaryGraphHelper} helper + * Instance of SummaryGraphHelper. + */ + renderMiddleIterations( + pathList, + state, + mainIterationStartTime, + firstSectionCount, + middleSectionCount, + helper + ) { + const offset = mainIterationStartTime + firstSectionCount * state.duration; + for (let i = 0; i < middleSectionCount; i++) { + // Get the path segments of each iteration. + const startTime = offset + i * state.duration; + const endTime = startTime + state.duration; + const segments = helper.createPathSegments(startTime, endTime); + pathList.push( + dom.path({ + className: "animation-iteration-path", + d: helper.toPathString(segments), + }) + ); + } + } + + /** + * Render last section of iterations and add a <path> element to given pathList. + * This section is only useful in cases where iterationStart has decimals. + * + * @param {Array} pathList + * Add rendered <path> elements to this array. + * @param {Object} state + * State of animation. + * @param {Number} mainIterationStartTime + * Starting time of main iteration. + * @param {Number} firstSectionCount + * Iteration count of first section. + * @param {Number} middleSectionCount + * Iteration count of middle section. + * @param {Number} lastSectionCount + * Iteration count of last section. + * @param {SummaryGraphHelper} helper + * Instance of SummaryGraphHelper. + */ + renderLastIteration( + pathList, + state, + mainIterationStartTime, + firstSectionCount, + middleSectionCount, + lastSectionCount, + helper + ) { + const startTime = + mainIterationStartTime + + (firstSectionCount + middleSectionCount) * state.duration; + const endTime = startTime + lastSectionCount * state.duration; + const segments = helper.createPathSegments(startTime, endTime); + pathList.push( + dom.path({ + className: "animation-iteration-path", + d: helper.toPathString(segments), + }) + ); + } + + /** + * Render infinity iterations and add <path> elements to given pathList. + * + * @param {Array} pathList + * Add rendered <path> elements to this array. + * @param {Object} state + * State of animation. + * @param {Number} mainIterationStartTime + * Starting time of main iteration. + * @param {Number} firstSectionCount + * Iteration count of first section. + * @param {SummaryGraphHelper} helper + * Instance of SummaryGraphHelper. + */ + renderInfinity( + pathList, + state, + mainIterationStartTime, + firstSectionCount, + helper + ) { + // Calculate the number of iterations to display, + // with a maximum of MAX_INFINITE_ANIMATIONS_ITERATIONS + let uncappedInfinityIterationCount = + (helper.totalDuration - firstSectionCount * state.duration) / + state.duration; + // If there is a small floating point error resulting in, e.g. 1.0000001 + // ceil will give us 2 so round first. + uncappedInfinityIterationCount = parseFloat( + uncappedInfinityIterationCount.toPrecision(6) + ); + const infinityIterationCount = Math.min( + MAX_INFINITE_ANIMATIONS_ITERATIONS, + Math.ceil(uncappedInfinityIterationCount) + ); + + // Append first full iteration path. + const firstStartTime = + mainIterationStartTime + firstSectionCount * state.duration; + const firstEndTime = firstStartTime + state.duration; + const firstSegments = helper.createPathSegments( + firstStartTime, + firstEndTime + ); + pathList.push( + dom.path({ + className: "animation-iteration-path", + d: helper.toPathString(firstSegments), + }) + ); + + // Append other iterations. We can copy first segments. + const isAlternate = state.direction.match(/alternate/); + for (let i = 1; i < infinityIterationCount; i++) { + const startTime = firstStartTime + i * state.duration; + let segments; + if (isAlternate && i % 2) { + // Copy as reverse. + segments = firstSegments.map(segment => { + return { x: firstEndTime - segment.x + startTime, y: segment.y }; + }); + } else { + // Copy as is. + segments = firstSegments.map(segment => { + return { x: segment.x - firstStartTime + startTime, y: segment.y }; + }); + } + pathList.push( + dom.path({ + className: "animation-iteration-path infinity", + d: helper.toPathString(segments), + }) + ); + } + } + + /** + * Render infinity duration. + * + * @param {Array} pathList + * Add rendered <path> elements to this array. + * @param {Object} state + * State of animation. + * @param {Number} mainIterationStartTime + * Starting time of main iteration. + * @param {SummaryGraphHelper} helper + * Instance of SummaryGraphHelper. + */ + renderInfinityDuration(pathList, state, mainIterationStartTime, helper) { + const startSegment = helper.getSegment(mainIterationStartTime); + const endSegment = { x: helper.totalDuration, y: startSegment.y }; + const segments = [startSegment, endSegment]; + pathList.push( + dom.path({ + className: "animation-iteration-path infinity-duration", + d: helper.toPathString(segments), + }) + ); + } + + /** + * Render 'endDelay' part in animation and add a <path> element to given pathList. + * + * @param {Array} pathList + * Add rendered <path> element to this array. + * @param {Object} state + * State of animation. + * @param {Number} mainIterationStartTime + * Starting time of main iteration. + * @param {Number} iterationCount + * Iteration count of whole animation. + * @param {SummaryGraphHelper} helper + * Instance of SummaryGraphHelper. + */ + renderEndDelay( + pathList, + state, + mainIterationStartTime, + iterationCount, + helper + ) { + const startTime = mainIterationStartTime + iterationCount * state.duration; + const startSegment = helper.getSegment(startTime); + const endSegment = { x: startTime + state.endDelay, y: startSegment.y }; + pathList.push( + dom.path({ + className: "animation-enddelay-path", + d: helper.toPathString([startSegment, endSegment]), + }) + ); + } + + /** + * Render 'fill' for forwards part in animation and + * add a <path> element to given pathList. + * + * @param {Array} pathList + * Add rendered <path> element to this array. + * @param {Object} state + * State of animation. + * @param {Number} mainIterationStartTime + * Starting time of main iteration. + * @param {Number} iterationCount + * Iteration count of whole animation. + * @param {SummaryGraphHelper} helper + * Instance of SummaryGraphHelper. + */ + renderForwardsFill( + pathList, + state, + mainIterationStartTime, + iterationCount, + helper + ) { + const startTime = + mainIterationStartTime + + iterationCount * state.duration + + (state.endDelay > 0 ? state.endDelay : 0); + const startSegment = helper.getSegment(startTime); + const endSegment = { x: helper.totalDuration, y: startSegment.y }; + pathList.push( + dom.path({ + className: "animation-fill-forwards-path", + d: helper.toPathString([startSegment, endSegment]), + }) + ); + } +} + +module.exports = TimingPath; diff --git a/devtools/client/inspector/animation/components/graph/moz.build b/devtools/client/inspector/animation/components/graph/moz.build new file mode 100644 index 0000000000..866bdd30ce --- /dev/null +++ b/devtools/client/inspector/animation/components/graph/moz.build @@ -0,0 +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/. + +DevToolsModules( + "AnimationName.js", + "ComputedTimingPath.js", + "DelaySign.js", + "EffectTimingPath.js", + "EndDelaySign.js", + "NegativeDelayPath.js", + "NegativeEndDelayPath.js", + "NegativePath.js", + "SummaryGraph.js", + "SummaryGraphPath.js", + "TimingPath.js", +) diff --git a/devtools/client/inspector/animation/components/keyframes-graph/ColorPath.js b/devtools/client/inspector/animation/components/keyframes-graph/ColorPath.js new file mode 100644 index 0000000000..120b61c73b --- /dev/null +++ b/devtools/client/inspector/animation/components/keyframes-graph/ColorPath.js @@ -0,0 +1,209 @@ +/* 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 dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const { colorUtils } = require("resource://devtools/shared/css/color.js"); + +const ComputedStylePath = require("resource://devtools/client/inspector/animation/components/keyframes-graph/ComputedStylePath.js"); + +const DEFAULT_COLOR = { r: 0, g: 0, b: 0, a: 1 }; + +/* Count for linearGradient ID */ +let LINEAR_GRADIENT_ID_COUNT = 0; + +class ColorPath extends ComputedStylePath { + constructor(props) { + super(props); + + this.state = this.propToState(props); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + this.setState(this.propToState(nextProps)); + } + + getPropertyName() { + return "color"; + } + + getPropertyValue(keyframe) { + return keyframe.value; + } + + propToState({ keyframes, name }) { + const maxObject = { distance: -Number.MAX_VALUE }; + + for (let i = 0; i < keyframes.length - 1; i++) { + const value1 = getRGBA(name, keyframes[i].value); + for (let j = i + 1; j < keyframes.length; j++) { + const value2 = getRGBA(name, keyframes[j].value); + const distance = getRGBADistance(value1, value2); + + if (maxObject.distance >= distance) { + continue; + } + + maxObject.distance = distance; + maxObject.value1 = value1; + maxObject.value2 = value2; + } + } + + const maxDistance = maxObject.distance; + const baseValue = + maxObject.value1 < maxObject.value2 ? maxObject.value1 : maxObject.value2; + + return { baseValue, maxDistance, name }; + } + + toSegmentValue(computedStyle) { + const { baseValue, maxDistance, name } = this.state; + const value = getRGBA(name, computedStyle); + return getRGBADistance(baseValue, value) / maxDistance; + } + + /** + * Overide parent's method. + */ + renderEasingHint() { + const { easingHintStrokeWidth, graphHeight, keyframes, totalDuration } = + this.props; + + const hints = []; + + for (let i = 0; i < keyframes.length - 1; i++) { + const startKeyframe = keyframes[i]; + const endKeyframe = keyframes[i + 1]; + const startTime = startKeyframe.offset * totalDuration; + const endTime = endKeyframe.offset * totalDuration; + + const g = dom.g( + { + className: "hint", + }, + dom.title({}, startKeyframe.easing), + dom.rect({ + x: startTime, + y: -graphHeight, + height: graphHeight, + width: endTime - startTime, + }), + dom.line({ + x1: startTime, + y1: -graphHeight, + x2: endTime, + y2: -graphHeight, + style: { + "stroke-width": easingHintStrokeWidth, + }, + }) + ); + hints.push(g); + } + + return hints; + } + + /** + * Overide parent's method. + */ + renderPathSegments(segments) { + for (const segment of segments) { + segment.y = 1; + } + + const lastSegment = segments[segments.length - 1]; + const id = `color-property-${LINEAR_GRADIENT_ID_COUNT++}`; + const path = super.renderPathSegments(segments, { fill: `url(#${id})` }); + const linearGradient = dom.linearGradient( + { id }, + segments.map(segment => { + return dom.stop({ + stopColor: segment.computedStyle, + offset: segment.x / lastSegment.x, + }); + }) + ); + + return [path, linearGradient]; + } + + render() { + return dom.g( + { + className: "color-path", + }, + super.renderGraph() + ); + } +} + +/** + * Parse given RGBA string. + * + * @param {String} propertyName + * @param {String} colorString + * e.g. rgb(0, 0, 0) or rgba(0, 0, 0, 0.5) and so on. + * @return {Object} + * RGBA {r: r, g: g, b: b, a: a}. + */ +function getRGBA(propertyName, colorString) { + // Special handling for CSS property which can specify the not normal CSS color value. + switch (propertyName) { + case "caret-color": { + // This property can specify "auto" keyword. + if (colorString === "auto") { + return DEFAULT_COLOR; + } + break; + } + case "scrollbar-color": { + // This property can specify "auto", "dark", "light" keywords and multiple colors. + if ( + ["auto", "dark", "light"].includes(colorString) || + colorString.indexOf(" ") > 0 + ) { + return DEFAULT_COLOR; + } + break; + } + } + + const color = new colorUtils.CssColor(colorString); + return color.getRGBATuple(); +} + +/** + * Return the distance from give two RGBA. + * + * @param {Object} rgba1 + * RGBA (format is same to getRGBA) + * @param {Object} rgba2 + * RGBA (format is same to getRGBA) + * @return {Number} + * The range is 0 - 1.0. + */ +function getRGBADistance(rgba1, rgba2) { + const startA = rgba1.a; + const startR = rgba1.r * startA; + const startG = rgba1.g * startA; + const startB = rgba1.b * startA; + const endA = rgba2.a; + const endR = rgba2.r * endA; + const endG = rgba2.g * endA; + const endB = rgba2.b * endA; + const diffA = startA - endA; + const diffR = startR - endR; + const diffG = startG - endG; + const diffB = startB - endB; + return Math.sqrt( + diffA * diffA + diffR * diffR + diffG * diffG + diffB * diffB + ); +} + +module.exports = ColorPath; diff --git a/devtools/client/inspector/animation/components/keyframes-graph/ComputedStylePath.js b/devtools/client/inspector/animation/components/keyframes-graph/ComputedStylePath.js new file mode 100644 index 0000000000..1da5c4da96 --- /dev/null +++ b/devtools/client/inspector/animation/components/keyframes-graph/ComputedStylePath.js @@ -0,0 +1,245 @@ +/* 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 { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const { + createPathSegments, + DEFAULT_DURATION_RESOLUTION, + getPreferredProgressThresholdByKeyframes, + toPathString, +} = require("resource://devtools/client/inspector/animation/utils/graph-helper.js"); + +/* + * This class is an abstraction for computed style path of keyframes. + * Subclass of this should implement the following methods: + * + * getPropertyName() + * Returns property name which will be animated. + * @return {String} + * e.g. opacity + * + * getPropertyValue(keyframe) + * Returns value which uses as animated keyframe value from given parameter. + * @param {Object} keyframe + * @return {String||Number} + * e.g. 0 + * + * toSegmentValue(computedStyle) + * Convert computed style to segment value of graph. + * @param {String||Number} + * e.g. 0 + * @return {Number} + * e.g. 0 (should be 0 - 1.0) + */ +class ComputedStylePath extends PureComponent { + static get propTypes() { + return { + componentWidth: PropTypes.number.isRequired, + easingHintStrokeWidth: PropTypes.number.isRequired, + graphHeight: PropTypes.number.isRequired, + keyframes: PropTypes.array.isRequired, + simulateAnimation: PropTypes.func.isRequired, + totalDuration: PropTypes.number.isRequired, + }; + } + + /** + * Return an array containing the path segments between the given start and + * end keyframe values. + * + * @param {Object} startKeyframe + * Starting keyframe. + * @param {Object} endKeyframe + * Ending keyframe. + * @return {Array} + * Array of path segment. + * [{x: {Number} time, y: {Number} segment value}, ...] + */ + getPathSegments(startKeyframe, endKeyframe) { + const { componentWidth, simulateAnimation, totalDuration } = this.props; + + const propertyName = this.getPropertyName(); + const offsetDistance = endKeyframe.offset - startKeyframe.offset; + const duration = offsetDistance * totalDuration; + + const keyframes = [startKeyframe, endKeyframe].map((keyframe, index) => { + return { + offset: index, + easing: keyframe.easing, + [getJsPropertyName(propertyName)]: this.getPropertyValue(keyframe), + }; + }); + const effect = { + duration, + fill: "forwards", + }; + + const simulatedAnimation = simulateAnimation(keyframes, effect, true); + + if (!simulatedAnimation) { + return null; + } + + const simulatedElement = simulatedAnimation.effect.target; + const win = simulatedElement.ownerGlobal; + const threshold = getPreferredProgressThresholdByKeyframes(keyframes); + + const getSegment = time => { + simulatedAnimation.currentTime = time; + const computedStyle = win + .getComputedStyle(simulatedElement) + .getPropertyValue(propertyName); + + return { + computedStyle, + x: time, + y: this.toSegmentValue(computedStyle), + }; + }; + + const segments = createPathSegments( + 0, + duration, + duration / componentWidth, + threshold, + DEFAULT_DURATION_RESOLUTION, + getSegment + ); + const offset = startKeyframe.offset * totalDuration; + + for (const segment of segments) { + segment.x += offset; + } + + return segments; + } + + /** + * Render easing hint from given path segments. + * + * @param {Array} segments + * Path segments. + * @return {Element} + * Element which represents easing hint. + */ + renderEasingHint(segments) { + const { easingHintStrokeWidth, keyframes, totalDuration } = this.props; + + const hints = []; + + for (let i = 0, indexOfSegments = 0; i < keyframes.length - 1; i++) { + const startKeyframe = keyframes[i]; + const endKeyframe = keyframes[i + 1]; + const endTime = endKeyframe.offset * totalDuration; + const hintSegments = []; + + for (; indexOfSegments < segments.length; indexOfSegments++) { + const segment = segments[indexOfSegments]; + hintSegments.push(segment); + + if (startKeyframe.offset === endKeyframe.offset) { + hintSegments.push(segments[++indexOfSegments]); + break; + } else if (segment.x === endTime) { + break; + } + } + + const g = dom.g( + { + className: "hint", + }, + dom.title({}, startKeyframe.easing), + dom.path({ + d: + `M${hintSegments[0].x},${hintSegments[0].y} ` + + toPathString(hintSegments), + style: { + "stroke-width": easingHintStrokeWidth, + }, + }) + ); + + hints.push(g); + } + + return hints; + } + + /** + * Render graph. This method returns React dom. + * + * @return {Element} + */ + renderGraph() { + const { keyframes } = this.props; + + const segments = []; + + for (let i = 0; i < keyframes.length - 1; i++) { + const startKeyframe = keyframes[i]; + const endKeyframe = keyframes[i + 1]; + const keyframesSegments = this.getPathSegments( + startKeyframe, + endKeyframe + ); + + if (!keyframesSegments) { + return null; + } + + segments.push(...keyframesSegments); + } + + return [this.renderPathSegments(segments), this.renderEasingHint(segments)]; + } + + /** + * Return react dom fron given path segments. + * + * @param {Array} segments + * @param {Object} style + * @return {Element} + */ + renderPathSegments(segments, style) { + const { graphHeight } = this.props; + + for (const segment of segments) { + segment.y *= graphHeight; + } + + let d = `M${segments[0].x},0 `; + d += toPathString(segments); + d += `L${segments[segments.length - 1].x},0 Z`; + + return dom.path({ d, style }); + } +} + +/** + * Convert given CSS property name to JavaScript CSS name. + * + * @param {String} cssPropertyName + * CSS property name (e.g. background-color). + * @return {String} + * JavaScript CSS property name (e.g. backgroundColor). + */ +function getJsPropertyName(cssPropertyName) { + if (cssPropertyName == "float") { + return "cssFloat"; + } + // https://drafts.csswg.org/cssom/#css-property-to-idl-attribute + return cssPropertyName.replace(/-([a-z])/gi, (str, group) => { + return group.toUpperCase(); + }); +} + +module.exports = ComputedStylePath; diff --git a/devtools/client/inspector/animation/components/keyframes-graph/DiscretePath.js b/devtools/client/inspector/animation/components/keyframes-graph/DiscretePath.js new file mode 100644 index 0000000000..26e5373f7c --- /dev/null +++ b/devtools/client/inspector/animation/components/keyframes-graph/DiscretePath.js @@ -0,0 +1,67 @@ +/* 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 dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const ComputedStylePath = require("resource://devtools/client/inspector/animation/components/keyframes-graph/ComputedStylePath.js"); + +class DiscretePath extends ComputedStylePath { + static get propTypes() { + return { + name: PropTypes.string.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = this.propToState(props); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + this.setState(this.propToState(nextProps)); + } + + getPropertyName() { + return this.props.name; + } + + getPropertyValue(keyframe) { + return keyframe.value; + } + + propToState({ getComputedStyle, keyframes, name }) { + const discreteValues = []; + + for (const keyframe of keyframes) { + const style = getComputedStyle(name, { [name]: keyframe.value }); + + if (!discreteValues.includes(style)) { + discreteValues.push(style); + } + } + + return { discreteValues }; + } + + toSegmentValue(computedStyle) { + const { discreteValues } = this.state; + return discreteValues.indexOf(computedStyle) / (discreteValues.length - 1); + } + + render() { + return dom.g( + { + className: "discrete-path", + }, + super.renderGraph() + ); + } +} + +module.exports = DiscretePath; diff --git a/devtools/client/inspector/animation/components/keyframes-graph/DistancePath.js b/devtools/client/inspector/animation/components/keyframes-graph/DistancePath.js new file mode 100644 index 0000000000..3436366c30 --- /dev/null +++ b/devtools/client/inspector/animation/components/keyframes-graph/DistancePath.js @@ -0,0 +1,34 @@ +/* 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 dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +const ComputedStylePath = require("resource://devtools/client/inspector/animation/components/keyframes-graph/ComputedStylePath.js"); + +class DistancePath extends ComputedStylePath { + getPropertyName() { + return "opacity"; + } + + getPropertyValue(keyframe) { + return keyframe.distance; + } + + toSegmentValue(computedStyle) { + return computedStyle; + } + + render() { + return dom.g( + { + className: "distance-path", + }, + super.renderGraph() + ); + } +} + +module.exports = DistancePath; diff --git a/devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerItem.js b/devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerItem.js new file mode 100644 index 0000000000..4212fdb88f --- /dev/null +++ b/devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerItem.js @@ -0,0 +1,33 @@ +/* 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 { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +class KeyframeMarkerItem extends PureComponent { + static get propTypes() { + return { + keyframe: PropTypes.object.isRequired, + }; + } + + render() { + const { keyframe } = this.props; + + return dom.li({ + className: "keyframe-marker-item", + title: keyframe.value, + style: { + marginInlineStart: `${keyframe.offset * 100}%`, + }, + }); + } +} + +module.exports = KeyframeMarkerItem; diff --git a/devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerList.js b/devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerList.js new file mode 100644 index 0000000000..7fd112f641 --- /dev/null +++ b/devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerList.js @@ -0,0 +1,37 @@ +/* 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 { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const KeyframeMarkerItem = createFactory( + require("resource://devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerItem.js") +); + +class KeyframeMarkerList extends PureComponent { + static get propTypes() { + return { + keyframes: PropTypes.array.isRequired, + }; + } + + render() { + const { keyframes } = this.props; + + return dom.ul( + { + className: "keyframe-marker-list", + }, + keyframes.map(keyframe => KeyframeMarkerItem({ keyframe })) + ); + } +} + +module.exports = KeyframeMarkerList; diff --git a/devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraph.js b/devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraph.js new file mode 100644 index 0000000000..cbab6806d2 --- /dev/null +++ b/devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraph.js @@ -0,0 +1,52 @@ +/* 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 { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); + +const KeyframeMarkerList = createFactory( + require("resource://devtools/client/inspector/animation/components/keyframes-graph/KeyframeMarkerList.js") +); +const KeyframesGraphPath = createFactory( + require("resource://devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraphPath.js") +); + +class KeyframesGraph extends PureComponent { + static get propTypes() { + return { + getComputedStyle: PropTypes.func.isRequired, + keyframes: PropTypes.array.isRequired, + name: PropTypes.string.isRequired, + simulateAnimation: PropTypes.func.isRequired, + type: PropTypes.string.isRequired, + }; + } + + render() { + const { getComputedStyle, keyframes, name, simulateAnimation, type } = + this.props; + + return dom.div( + { + className: `keyframes-graph ${name}`, + }, + KeyframesGraphPath({ + getComputedStyle, + keyframes, + name, + simulateAnimation, + type, + }), + KeyframeMarkerList({ keyframes }) + ); + } +} + +module.exports = KeyframesGraph; diff --git a/devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraphPath.js b/devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraphPath.js new file mode 100644 index 0000000000..70c2720194 --- /dev/null +++ b/devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraphPath.js @@ -0,0 +1,111 @@ +/* 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 { + createFactory, + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js"); + +const ColorPath = createFactory( + require("resource://devtools/client/inspector/animation/components/keyframes-graph/ColorPath.js") +); +const DiscretePath = createFactory( + require("resource://devtools/client/inspector/animation/components/keyframes-graph/DiscretePath.js") +); +const DistancePath = createFactory( + require("resource://devtools/client/inspector/animation/components/keyframes-graph/DistancePath.js") +); + +const { + DEFAULT_EASING_HINT_STROKE_WIDTH, + DEFAULT_GRAPH_HEIGHT, + DEFAULT_KEYFRAMES_GRAPH_DURATION, +} = require("resource://devtools/client/inspector/animation/utils/graph-helper.js"); + +class KeyframesGraphPath extends PureComponent { + static get propTypes() { + return { + getComputedStyle: PropTypes.func.isRequired, + keyframes: PropTypes.array.isRequired, + name: PropTypes.string.isRequired, + simulateAnimation: PropTypes.func.isRequired, + type: PropTypes.string.isRequired, + }; + } + + constructor(props) { + super(props); + + this.state = { + componentHeight: 0, + componentWidth: 0, + }; + } + + componentDidMount() { + this.updateState(); + } + + getPathComponent(type) { + switch (type) { + case "color": + return ColorPath; + case "discrete": + return DiscretePath; + default: + return DistancePath; + } + } + + updateState() { + const thisEl = ReactDOM.findDOMNode(this); + this.setState({ + componentHeight: thisEl.parentNode.clientHeight, + componentWidth: thisEl.parentNode.clientWidth, + }); + } + + render() { + const { getComputedStyle, keyframes, name, simulateAnimation, type } = + this.props; + const { componentHeight, componentWidth } = this.state; + + if (!componentWidth) { + return dom.svg(); + } + + const pathComponent = this.getPathComponent(type); + const strokeWidthInViewBox = + (DEFAULT_EASING_HINT_STROKE_WIDTH / 2 / componentHeight) * + DEFAULT_GRAPH_HEIGHT; + + return dom.svg( + { + className: "keyframes-graph-path", + preserveAspectRatio: "none", + viewBox: + `0 -${DEFAULT_GRAPH_HEIGHT + strokeWidthInViewBox} ` + + `${DEFAULT_KEYFRAMES_GRAPH_DURATION} ` + + `${DEFAULT_GRAPH_HEIGHT + strokeWidthInViewBox * 2}`, + }, + pathComponent({ + componentWidth, + easingHintStrokeWidth: DEFAULT_EASING_HINT_STROKE_WIDTH, + getComputedStyle, + graphHeight: DEFAULT_GRAPH_HEIGHT, + keyframes, + name, + simulateAnimation, + totalDuration: DEFAULT_KEYFRAMES_GRAPH_DURATION, + }) + ); + } +} + +module.exports = KeyframesGraphPath; diff --git a/devtools/client/inspector/animation/components/keyframes-graph/moz.build b/devtools/client/inspector/animation/components/keyframes-graph/moz.build new file mode 100644 index 0000000000..1ff518e21d --- /dev/null +++ b/devtools/client/inspector/animation/components/keyframes-graph/moz.build @@ -0,0 +1,14 @@ +# 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/. + +DevToolsModules( + "ColorPath.js", + "ComputedStylePath.js", + "DiscretePath.js", + "DistancePath.js", + "KeyframeMarkerItem.js", + "KeyframeMarkerList.js", + "KeyframesGraph.js", + "KeyframesGraphPath.js", +) diff --git a/devtools/client/inspector/animation/components/moz.build b/devtools/client/inspector/animation/components/moz.build new file mode 100644 index 0000000000..cd8ecc05c4 --- /dev/null +++ b/devtools/client/inspector/animation/components/moz.build @@ -0,0 +1,30 @@ +# 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/. + +DIRS += ["graph", "keyframes-graph"] + +DevToolsModules( + "AnimatedPropertyItem.js", + "AnimatedPropertyList.js", + "AnimatedPropertyListContainer.js", + "AnimatedPropertyName.js", + "AnimationDetailContainer.js", + "AnimationDetailHeader.js", + "AnimationItem.js", + "AnimationList.js", + "AnimationListContainer.js", + "AnimationTarget.js", + "AnimationToolbar.js", + "App.js", + "CurrentTimeLabel.js", + "CurrentTimeScrubber.js", + "KeyframesProgressBar.js", + "NoAnimationPanel.js", + "PauseResumeButton.js", + "PlaybackRateSelector.js", + "ProgressInspectionPanel.js", + "RewindButton.js", + "TickLabels.js", + "TickLines.js", +) diff --git a/devtools/client/inspector/animation/current-time-timer.js b/devtools/client/inspector/animation/current-time-timer.js new file mode 100644 index 0000000000..4c08eb09ad --- /dev/null +++ b/devtools/client/inspector/animation/current-time-timer.js @@ -0,0 +1,75 @@ +/* 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"; + +/** + * In animation inspector, the scrubber and the progress bar moves along the current time + * of animation. However, the processing which sync with actual animations is heavy since + * we have to communication by the actor. The role of this class is to make the pseudo + * current time in animation inspector to proceed. + */ +class CurrentTimeTimer { + /** + * Constructor. + * + * @param {Object} timeScale + * @param {Bool} shouldStopAfterEndTime + * If need to stop the timer after animation end time, set true. + * @param {window} win + * Be used for requestAnimationFrame and performance. + * @param {Function} onUpdated + * Listener function to get updating. + * This function is called with 2 parameters. + * 1st: current time + * 2nd: if shouldStopAfterEndTime is true and + * the current time is over the end time, true is given. + */ + constructor(timeScale, shouldStopAfterEndTime, win, onUpdated) { + this.baseCurrentTime = timeScale.getCurrentTime(); + this.endTime = timeScale.getDuration(); + this.timerStartTime = win.performance.now(); + + this.shouldStopAfterEndTime = shouldStopAfterEndTime; + this.onUpdated = onUpdated; + this.win = win; + this.next = this.next.bind(this); + } + + destroy() { + this.stop(); + this.onUpdated = null; + this.win = null; + } + + /** + * Proceed the pseudo current time. + */ + next() { + if (this.doStop) { + return; + } + + const currentTime = + this.baseCurrentTime + this.win.performance.now() - this.timerStartTime; + + if (this.endTime < currentTime && this.shouldStopAfterEndTime) { + this.onUpdated(this.endTime, true); + return; + } + + this.onUpdated(currentTime); + this.win.requestAnimationFrame(this.next); + } + + start() { + this.next(); + } + + stop() { + this.doStop = true; + } +} + +module.exports = CurrentTimeTimer; diff --git a/devtools/client/inspector/animation/moz.build b/devtools/client/inspector/animation/moz.build new file mode 100644 index 0000000000..fb6fda4f08 --- /dev/null +++ b/devtools/client/inspector/animation/moz.build @@ -0,0 +1,12 @@ +# 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/. + +DIRS += ["actions", "components", "reducers", "utils"] + +BROWSER_CHROME_MANIFESTS += ["test/browser.ini"] + +DevToolsModules("animation.js", "current-time-timer.js") + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Inspector: Animations") diff --git a/devtools/client/inspector/animation/reducers/animations.js b/devtools/client/inspector/animation/reducers/animations.js new file mode 100644 index 0000000000..ead3e84147 --- /dev/null +++ b/devtools/client/inspector/animation/reducers/animations.js @@ -0,0 +1,117 @@ +/* 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 { + UPDATE_ANIMATIONS, + UPDATE_DETAIL_VISIBILITY, + UPDATE_ELEMENT_PICKER_ENABLED, + UPDATE_HIGHLIGHTED_NODE, + UPDATE_PLAYBACK_RATES, + UPDATE_SELECTED_ANIMATION, + UPDATE_SIDEBAR_SIZE, +} = require("resource://devtools/client/inspector/animation/actions/index.js"); + +loader.lazyRequireGetter( + this, + "TimeScale", + "resource://devtools/client/inspector/animation/utils/timescale.js" +); + +const INITIAL_STATE = { + animations: [], + detailVisibility: false, + elementPickerEnabled: false, + highlightedNode: null, + playbackRates: [], + selectedAnimation: null, + sidebarSize: { + height: 0, + width: 0, + }, + timeScale: null, +}; + +const reducers = { + [UPDATE_ANIMATIONS](state, { animations }) { + let detailVisibility = state.detailVisibility; + let selectedAnimation = state.selectedAnimation; + + if ( + !state.selectedAnimation || + !animations.find( + animation => animation.actorID === selectedAnimation.actorID + ) + ) { + selectedAnimation = animations.length === 1 ? animations[0] : null; + detailVisibility = !!selectedAnimation; + } + + const playbackRates = getPlaybackRates(state.playbackRates, animations); + + return Object.assign({}, state, { + animations, + detailVisibility, + playbackRates, + selectedAnimation, + timeScale: new TimeScale(animations), + }); + }, + + [UPDATE_DETAIL_VISIBILITY](state, { detailVisibility }) { + const selectedAnimation = detailVisibility ? state.selectedAnimation : null; + + return Object.assign({}, state, { + detailVisibility, + selectedAnimation, + }); + }, + + [UPDATE_ELEMENT_PICKER_ENABLED](state, { elementPickerEnabled }) { + return Object.assign({}, state, { + elementPickerEnabled, + }); + }, + + [UPDATE_HIGHLIGHTED_NODE](state, { highlightedNode }) { + return Object.assign({}, state, { + highlightedNode, + }); + }, + + [UPDATE_PLAYBACK_RATES](state) { + return Object.assign({}, state, { + playbackRates: getPlaybackRates([], state.animations), + }); + }, + + [UPDATE_SELECTED_ANIMATION](state, { selectedAnimation }) { + const detailVisibility = !!selectedAnimation; + + return Object.assign({}, state, { + detailVisibility, + selectedAnimation, + }); + }, + + [UPDATE_SIDEBAR_SIZE](state, { sidebarSize }) { + return Object.assign({}, state, { + sidebarSize, + }); + }, +}; + +function getPlaybackRates(basePlaybackRate, animations) { + return [ + ...new Set( + animations.map(a => a.state.playbackRate).concat(basePlaybackRate) + ), + ]; +} + +module.exports = function (state = INITIAL_STATE, action) { + const reducer = reducers[action.type]; + return reducer ? reducer(state, action) : state; +}; diff --git a/devtools/client/inspector/animation/reducers/moz.build b/devtools/client/inspector/animation/reducers/moz.build new file mode 100644 index 0000000000..8b20a9f6cd --- /dev/null +++ b/devtools/client/inspector/animation/reducers/moz.build @@ -0,0 +1,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/. + +DevToolsModules( + "animations.js", +) diff --git a/devtools/client/inspector/animation/test/browser.ini b/devtools/client/inspector/animation/test/browser.ini new file mode 100644 index 0000000000..d2eea4f782 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser.ini @@ -0,0 +1,118 @@ +[DEFAULT] +prefs = + dom.animations.mainthread-synchronization-with-geometric-animations=true + dom.svg.pathSeg.enabled=true +tags = devtools +subsuite = devtools +support-files = + current-time-scrubber_head.js + doc_custom_playback_rate.html + doc_infinity_duration.html + doc_multi_easings.html + doc_multi_keyframes.html + doc_multi_timings.html + doc_mutations_add_remove_immediately.html + doc_mutations_fast.html + doc_negative_playback_rate.html + doc_overflowed_delay_end_delay.html + doc_pseudo.html + doc_short_duration.html + doc_simple_animation.html + doc_special_colors.html + head.js + keyframes-graph_keyframe-marker_head.js + summary-graph_computed-timing-path_head.js + summary-graph_delay-sign_head.js + summary-graph_end-delay-sign_head.js + !/devtools/client/inspector/test/head.js + !/devtools/client/inspector/test/shared-head.js + !/devtools/client/shared/test/shared-head.js + !/devtools/client/shared/test/telemetry-test-helpers.js + !/devtools/client/shared/test/highlighter-test-actor.js + +[browser_animation_animated-property-list.js] +[browser_animation_animated-property-list_unchanged-items.js] +[browser_animation_animated-property-name.js] +[browser_animation_animation-detail_close-button.js] +[browser_animation_animation-detail_title.js] +[browser_animation_animation-detail_visibility.js] +[browser_animation_animation-list.js] +[browser_animation_animation-list_one-animation-select.js] +[browser_animation_animation-list_select.js] +[browser_animation_animation-target.js] +skip-if = win10_2004 # Bug 1723573 +[browser_animation_animation-target_highlight.js] +skip-if = + (apple_catalina && !debug) # Disabled in Bug 1713158. Intemittent bug: Bug 1665011 + (os == "linux" && !debug && !asan && !swgl && !ccov) # Bug 1665011 + win10_2004 # Bug 1723573 + win11_2009 # Bug 1798331 +[browser_animation_animation-target_select.js] +[browser_animation_animation-timeline-tick.js] +[browser_animation_css-transition-with-playstate-idle.js] +[browser_animation_current-time-label.js] +[browser_animation_current-time-scrubber.js] +[browser_animation_current-time-scrubber-rtl.js] +skip-if = + os == "linux" && debug # Bug 1721716 +[browser_animation_current-time-scrubber_each-different-creation-time-animations.js] +[browser_animation_current-time-scrubber-with-negative-delay.js] +[browser_animation_empty_on_invalid_nodes.js] +[browser_animation_fission_switch-target.js] +[browser_animation_indication-bar.js] +[browser_animation_infinity-duration_current-time-scrubber.js] +[browser_animation_infinity-duration_summary-graph.js] +[browser_animation_infinity-duration_tick-label.js] +[browser_animation_keyframes-graph_computed-value-path-01.js] +[browser_animation_keyframes-graph_computed-value-path-02.js] +[browser_animation_keyframes-graph_computed-value-path-03.js] +[browser_animation_keyframes-graph_computed-value-path_easing-hint.js] +skip-if = (verify && !debug) +[browser_animation_keyframes-graph_keyframe-marker.js] +[browser_animation_keyframes-graph_keyframe-marker-rtl.js] +[browser_animation_keyframes-graph_special-colors.js] +[browser_animation_keyframes-progress-bar.js] +skip-if = (os == "win" && ccov) # Bug 1490981 +[browser_animation_keyframes-progress-bar_after-resuming.js] +[browser_animation_logic_adjust-time.js] +[browser_animation_logic_adjust-time-with-playback-rate.js] +[browser_animation_logic_auto-stop.js] +[browser_animation_logic_avoid-updating-during-hiding.js] +[browser_animation_logic_created-time.js] +[browser_animation_logic_mutations.js] +[browser_animation_logic_mutations_add_remove_immediately.js] +[browser_animation_logic_mutations_fast.js] +skip-if = + debug + (os == "win" && bits == 32) # Bug 1567800 + (os == "linux" && !asan && !debug && !swgl && !ccov) # Bug 1567800 +[browser_animation_logic_mutations_properties.js] +[browser_animation_logic_overflowed_delay_end-delay.js] +skip-if = debug #bug 1480027 +[browser_animation_logic_scroll-amount.js] +[browser_animation_pause-resume-button.js] +[browser_animation_pause-resume-button_end-time.js] +skip-if = + os == 'linux' && bits == 64 && debug # Bug 1767699 +[browser_animation_pause-resume-button_respectively.js] +[browser_animation_pause-resume-button_spacebar.js] +[browser_animation_playback-rate-selector.js] +[browser_animation_pseudo-element.js] +[browser_animation_rewind-button.js] +[browser_animation_short-duration.js] +[browser_animation_summary-graph_animation-name.js] +[browser_animation_summary-graph_compositor.js] +[browser_animation_summary-graph_computed-timing-path_1.js] +[browser_animation_summary-graph_computed-timing-path_2.js] +[browser_animation_summary-graph_computed-timing-path_different-timescale.js] +[browser_animation_summary-graph_delay-sign.js] +[browser_animation_summary-graph_delay-sign-rtl.js] +[browser_animation_summary-graph_end-delay-sign.js] +[browser_animation_summary-graph_end-delay-sign-rtl.js] +[browser_animation_summary-graph_effect-timing-path.js] +[browser_animation_summary-graph_layout-by-seek.js] +[browser_animation_summary-graph_negative-delay-path.js] +[browser_animation_summary-graph_negative-end-delay-path.js] +[browser_animation_summary-graph_tooltip.js] +[browser_animation_timing_negative-playback-rate_summary-graph.js] +[browser_animation_timing_negative-playback-rate_current-time-scrubber.js] diff --git a/devtools/client/inspector/animation/test/browser_animation_animated-property-list.js b/devtools/client/inspector/animation/test/browser_animation_animated-property-list.js new file mode 100644 index 0000000000..2516f47c79 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_animated-property-list.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test following animated property list test. +// 1. Existence for animated property list. +// 2. Number of animated property item. + +const TEST_DATA = [ + { + targetClass: "animated", + expectedNumber: 1, + }, + { + targetClass: "compositor-notall", + expectedNumber: 3, + }, +]; + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`)); + const { animationInspector, panel } = await openAnimationInspector(); + + info("Checking animated property list and items existence at initial"); + ok( + !panel.querySelector(".animated-property-list"), + "The animated-property-list should not be in the DOM at initial" + ); + + for (const { targetClass, expectedNumber } of TEST_DATA) { + info( + `Checking animated-property-list and items existence at ${targetClass}` + ); + await clickOnAnimationByTargetSelector( + animationInspector, + panel, + `.${targetClass}` + ); + + await waitUntil( + () => + panel.querySelectorAll(".animated-property-item").length === + expectedNumber + ); + ok( + true, + `The number of animated-property-list should be ${expectedNumber} at ${targetClass}` + ); + } +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_animated-property-list_unchanged-items.js b/devtools/client/inspector/animation/test/browser_animation_animated-property-list_unchanged-items.js new file mode 100644 index 0000000000..22b889297f --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_animated-property-list_unchanged-items.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the position and the class of unchanged animated property items. + +const TEST_DATA = [ + { property: "background-color", isUnchanged: false }, + { property: "padding-left", isUnchanged: false }, + { property: "background-attachment", isUnchanged: true }, + { property: "background-clip", isUnchanged: true }, + { property: "background-image", isUnchanged: true }, + { property: "background-origin", isUnchanged: true }, + { property: "background-position-x", isUnchanged: true }, + { property: "background-position-y", isUnchanged: true }, + { property: "background-repeat", isUnchanged: true }, + { property: "background-size", isUnchanged: true }, + { property: "padding-bottom", isUnchanged: true }, + { property: "padding-right", isUnchanged: true }, + { property: "padding-top", isUnchanged: true }, +]; + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".longhand"]); + const { panel } = await openAnimationInspector(); + + info("Checking unchanged animated property item"); + const itemEls = panel.querySelectorAll(".animated-property-item"); + is( + itemEls.length, + TEST_DATA.length, + `Count of animated property item should be ${TEST_DATA.length}` + ); + + for (let i = 0; i < TEST_DATA.length; i++) { + const { property, isUnchanged } = TEST_DATA[i]; + const itemEl = itemEls[i]; + + ok( + itemEl.querySelector(`.keyframes-graph.${property}`), + `Item of ${property} should display at here` + ); + + if (isUnchanged) { + ok( + itemEl.classList.contains("unchanged"), + "Animated property item should have 'unchanged' class" + ); + } else { + ok( + !itemEl.classList.contains("unchanged"), + "Animated property item should not have 'unchanged' class" + ); + } + } +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_animated-property-name.js b/devtools/client/inspector/animation/test/browser_animation_animated-property-name.js new file mode 100644 index 0000000000..e4b896b5ea --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_animated-property-name.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the following animated property name component features: +// * name of property +// * display compositor sign when the property was running on compositor. +// * display warning when the property is runnable on compositor but was not. + +const TEST_DATA = [ + { + property: "opacity", + isOnCompositor: true, + }, + { + property: "transform", + isWarning: true, + }, + { + property: "width", + }, +]; + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".compositor-notall"]); + const { panel } = await openAnimationInspector(); + + info("Checking animated property name component"); + const animatedPropertyNameEls = panel.querySelectorAll( + ".animated-property-name" + ); + is( + animatedPropertyNameEls.length, + TEST_DATA.length, + `Number of animated property name elements should be ${TEST_DATA.length}` + ); + + for (const [ + index, + animatedPropertyNameEl, + ] of animatedPropertyNameEls.entries()) { + const { property, isOnCompositor, isWarning } = TEST_DATA[index]; + + info(`Checking text content for ${property}`); + + const spanEl = animatedPropertyNameEl.querySelector("span"); + ok( + spanEl, + `<span> element should be in animated-property-name of ${property}` + ); + is(spanEl.textContent, property, `textContent should be ${property}`); + + info(`Checking compositor sign for ${property}`); + + if (isOnCompositor) { + ok( + animatedPropertyNameEl.classList.contains("compositor"), + "animatedPropertyNameEl should has .compositor class" + ); + isnot( + getComputedStyle(spanEl, "::before").width, + "auto", + "width of ::before pseud should not be auto" + ); + } else { + ok( + !animatedPropertyNameEl.classList.contains("compositor"), + "animatedPropertyNameEl should not have .compositor class" + ); + is( + getComputedStyle(spanEl, "::before").width, + "auto", + "width of ::before pseud should be auto" + ); + } + + info(`Checking warning for ${property}`); + + if (isWarning) { + ok( + animatedPropertyNameEl.classList.contains("warning"), + "animatedPropertyNameEl should has .warning class" + ); + is( + getComputedStyle(spanEl).textDecorationStyle, + "dotted", + "text-decoration-style of spanEl should be 'dotted'" + ); + is( + getComputedStyle(spanEl).textDecorationLine, + "underline", + "text-decoration-line of spanEl should be 'underline'" + ); + } else { + ok( + !animatedPropertyNameEl.classList.contains("warning"), + "animatedPropertyNameEl should not have .warning class" + ); + is( + getComputedStyle(spanEl).textDecorationStyle, + "solid", + "text-decoration-style of spanEl should be 'solid'" + ); + is( + getComputedStyle(spanEl).textDecorationLine, + "none", + "text-decoration-line of spanEl should be 'none'" + ); + } + } +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-detail_close-button.js b/devtools/client/inspector/animation/test/browser_animation_animation-detail_close-button.js new file mode 100644 index 0000000000..f7fa4cee70 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_animation-detail_close-button.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that whether close button in header of animation detail works. + +add_task(async function () { + await addTab(URL_ROOT + "doc_custom_playback_rate.html"); + const { animationInspector, panel } = await openAnimationInspector(); + + info("Checking close button in header of animation detail"); + await clickOnAnimation(animationInspector, panel, 0); + const detailEl = panel.querySelector("#animation-container .controlled"); + const win = panel.ownerGlobal; + isnot( + win.getComputedStyle(detailEl).display, + "none", + "detailEl should be visibled before clicking close button" + ); + clickOnDetailCloseButton(panel); + is( + win.getComputedStyle(detailEl).display, + "none", + "detailEl should be unvisibled after clicking close button" + ); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-detail_title.js b/devtools/client/inspector/animation/test/browser_animation_animation-detail_title.js new file mode 100644 index 0000000000..91dfd2e50f --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_animation-detail_title.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that whether title in header of animations detail. + +const TEST_DATA = [ + { + targetClass: "cssanimation-normal", + expectedTitle: "cssanimation — CSS Animation", + }, + { + targetClass: "delay-positive", + expectedTitle: "test-delay-animation — Script Animation", + }, + { + targetClass: "easing-step", + expectedTitle: "Script Animation", + }, +]; + +add_task(async function () { + await addTab(URL_ROOT + "doc_multi_timings.html"); + await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`)); + const { animationInspector, panel } = await openAnimationInspector(); + + info("Checking title in each header of animation detail"); + + for (const { targetClass, expectedTitle } of TEST_DATA) { + info(`Checking title at ${targetClass}`); + await clickOnAnimationByTargetSelector( + animationInspector, + panel, + `.${targetClass}` + ); + const titleEl = panel.querySelector(".animation-detail-title"); + is( + titleEl.textContent, + expectedTitle, + `Title of "${targetClass}" should be "${expectedTitle}"` + ); + } +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-detail_visibility.js b/devtools/client/inspector/animation/test/browser_animation_animation-detail_visibility.js new file mode 100644 index 0000000000..14f406a1a3 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_animation-detail_visibility.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that whether animations detail could be displayed if there is selected animation. + +add_task(async function () { + await addTab(URL_ROOT + "doc_custom_playback_rate.html"); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + + info("Checking animation detail visibility if animation was unselected"); + const detailEl = panel.querySelector("#animation-container .controlled"); + ok(detailEl, "The detail pane should be in the DOM"); + await assertDisplayStyle(detailEl, true, "detailEl should be unvisibled"); + + info( + "Checking animation detail visibility if animation was selected by click" + ); + await clickOnAnimation(animationInspector, panel, 0); + await assertDisplayStyle(detailEl, false, "detailEl should be visibled"); + + info( + "Checking animation detail visibility when choose node which has animations" + ); + await selectNode("html", inspector); + await assertDisplayStyle( + detailEl, + true, + "detailEl should be unvisibled after choose html node" + ); + + info( + "Checking animation detail visibility when choose node which has an animation" + ); + await selectNode("div", inspector); + await assertDisplayStyle( + detailEl, + false, + "detailEl should be visibled after choose .cssanimation-normal node" + ); +}); + +async function assertDisplayStyle(detailEl, isNoneExpected, description) { + const win = detailEl.ownerGlobal; + await waitUntil(() => { + const isNone = win.getComputedStyle(detailEl).display === "none"; + return isNone === isNoneExpected; + }); + ok(true, description); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-list.js b/devtools/client/inspector/animation/test/browser_animation_animation-list.js new file mode 100644 index 0000000000..4f2c4419b3 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_animation-list.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that whether animations ui could be displayed + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".animated", ".long"]); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + + info("Checking animation list and items existence"); + ok( + panel.querySelector(".animation-list"), + "The animation-list is in the DOM" + ); + is( + panel.querySelectorAll(".animation-list .animation-item").length, + animationInspector.state.animations.length, + "The number of animations displayed matches the number of animations" + ); + + info( + "Checking list and items existence after select a element which has an animation" + ); + await selectNode(".animated", inspector); + await waitUntil( + () => panel.querySelectorAll(".animation-list .animation-item").length === 1 + ); + ok( + true, + "The number of animations displayed should be 1 for .animated element" + ); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-list_one-animation-select.js b/devtools/client/inspector/animation/test/browser_animation_animation-list_one-animation-select.js new file mode 100644 index 0000000000..d84750385c --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_animation-list_one-animation-select.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether the animation item has been selected from first time +// if count of the animations is one. + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".animated"]); + const { panel } = await openAnimationInspector(); + + info("Checking whether an item element has been selected"); + is( + panel.querySelector(".animation-item").classList.contains("selected"), + true, + "The animation item should have 'selected' class" + ); + + info( + "Checking whether the element will be unselected after closing the detail pane" + ); + clickOnDetailCloseButton(panel); + is( + panel.querySelector(".animation-item").classList.contains("selected"), + false, + "The animation item should not have 'selected' class" + ); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-list_select.js b/devtools/client/inspector/animation/test/browser_animation_animation-list_select.js new file mode 100644 index 0000000000..0d8901ef46 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_animation-list_select.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether the animation items in the list were selectable. + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".animated", ".long"]); + const { animationInspector, panel } = await openAnimationInspector(); + + info("Checking whether 1st element will be selected"); + await clickOnAnimation(animationInspector, panel, 0); + assertSelection(panel, [true, false]); + + info("Checking whether 2nd element will be selected"); + await clickOnAnimation(animationInspector, panel, 1); + assertSelection(panel, [false, true]); + + info( + "Checking whether all elements will be unselected after closing the detail pane" + ); + clickOnDetailCloseButton(panel); + assertSelection(panel, [false, false]); +}); + +function assertSelection(panel, expectedResult) { + panel.querySelectorAll(".animation-item").forEach((item, index) => { + const shouldSelected = expectedResult[index]; + is( + item.classList.contains("selected"), + shouldSelected, + `Animation item[${index}] should ` + + `${shouldSelected ? "" : "not"} have 'selected' class` + ); + }); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-target.js b/devtools/client/inspector/animation/test/browser_animation_animation-target.js new file mode 100644 index 0000000000..e38ae18755 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_animation-target.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following AnimationTarget component works. +// * element existance +// * number of elements +// * content of element +// * title of inspect icon + +const TEST_DATA = [ + { expectedTextContent: "div.ball.animated" }, + { expectedTextContent: "div.ball.long" }, +]; + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".animated", ".long"]); + const { animationInspector, panel } = await openAnimationInspector(); + + info("Checking the animation target elements existance"); + const animationItemEls = panel.querySelectorAll( + ".animation-list .animation-item" + ); + is( + animationItemEls.length, + animationInspector.state.animations.length, + "Number of animation target element should be same to number of animations " + + "that displays" + ); + + for (let i = 0; i < animationItemEls.length; i++) { + const animationItemEl = animationItemEls[i]; + animationItemEl.scrollIntoView(false); + await waitUntil(() => animationItemEl.querySelector(".animation-target")); + + const animationTargetEl = + animationItemEl.querySelector(".animation-target"); + ok( + animationTargetEl, + "The animation target element should be in each animation item element" + ); + + info("Checking the content of animation target"); + const testData = TEST_DATA[i]; + is( + animationTargetEl.textContent, + testData.expectedTextContent, + "The target element's content is correct" + ); + ok( + animationTargetEl.querySelector(".objectBox"), + "objectBox is in the page exists" + ); + ok( + animationTargetEl.querySelector(".highlight-node").title, + INSPECTOR_L10N.getStr("inspector.nodePreview.highlightNodeLabel") + ); + } +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-target_highlight.js b/devtools/client/inspector/animation/test/browser_animation_animation-target_highlight.js new file mode 100644 index 0000000000..0ff5b08018 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_animation-target_highlight.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following highlighting related. +// * highlight when mouse over on a target node +// * unhighlight when mouse out from the above element +// * lock highlighting when click on the inspect icon in animation target component +// * add 'highlighting' class to animation target component during locking +// * mouseover locked target node +// * unlock highlighting when click on the above icon +// * lock highlighting when click on the other inspect icon +// * if the locked node has multi animations, +// the class will add to those animation target as well + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".animated", ".multi"]); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + const { waitForHighlighterTypeShown, waitForHighlighterTypeHidden } = + getHighlighterTestHelpers(inspector); + + info("Check highlighting when mouse over on a target node"); + const onHighlight = waitForHighlighterTypeShown( + inspector.highlighters.TYPES.BOXMODEL + ); + mouseOverOnTargetNode(animationInspector, panel, 0); + let data = await onHighlight; + assertNodeFront(data.nodeFront, "DIV", "ball animated"); + + info("Check unhighlighting when mouse out on a target node"); + const onUnhighlight = waitForHighlighterTypeHidden( + inspector.highlighters.TYPES.BOXMODEL + ); + mouseOutOnTargetNode(animationInspector, panel, 0); + await onUnhighlight; + ok(true, "Unhighlighted the targe node"); + + info("Check node is highlighted when the inspect icon is clicked"); + const onHighlighterShown = waitForHighlighterTypeShown( + inspector.highlighters.TYPES.BOXMODEL + ); + await clickOnInspectIcon(animationInspector, panel, 0); + data = await onHighlighterShown; + assertNodeFront(data.nodeFront, "DIV", "ball animated"); + await assertHighlight(panel, 0, true); + + info("Check if the animation target is still highlighted on mouse out"); + mouseOutOnTargetNode(animationInspector, panel, 0); + await wait(500); + await assertHighlight(panel, 0, true); + + info("Check no highlight event occur by mouse over locked target"); + let highlightEventCount = 0; + function onHighlighterHidden({ type }) { + if (type === inspector.highlighters.TYPES.BOXMODEL) { + highlightEventCount += 1; + } + } + inspector.highlighters.on("highlighter-hidden", onHighlighterHidden); + mouseOverOnTargetNode(animationInspector, panel, 0); + await wait(500); + is(highlightEventCount, 0, "Highlight event should not occur"); + inspector.highlighters.off("highlighter-hidden", onHighlighterHidden); + + info("Show persistent highlighter on an animation target"); + const onPersistentHighlighterShown = waitForHighlighterTypeShown( + inspector.highlighters.TYPES.SELECTOR + ); + await clickOnInspectIcon(animationInspector, panel, 1); + data = await onPersistentHighlighterShown; + assertNodeFront(data.nodeFront, "DIV", "ball multi"); + + info("Check the highlighted state of the animation targets"); + await assertHighlight(panel, 0, false); + await assertHighlight(panel, 1, true); + await assertHighlight(panel, 2, true); + + info("Hide persistent highlighter"); + const onPersistentHighlighterHidden = waitForHighlighterTypeHidden( + inspector.highlighters.TYPES.SELECTOR + ); + await clickOnInspectIcon(animationInspector, panel, 1); + await onPersistentHighlighterHidden; + + info("Check the highlighted state of the animation targets"); + await assertHighlight(panel, 0, false); + await assertHighlight(panel, 1, false); + await assertHighlight(panel, 2, false); +}); + +async function assertHighlight(panel, index, isHighlightExpected) { + const animationItemEl = await findAnimationItemByIndex(panel, index); + const animationTargetEl = animationItemEl.querySelector(".animation-target"); + + await waitUntil( + () => + animationTargetEl.classList.contains("highlighting") === + isHighlightExpected + ); + ok(true, `Highlighting class of animation target[${index}] is correct`); +} + +function assertNodeFront(nodeFront, tagName, classValue) { + 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, + classValue, + "The highlighted node has the correct class" + ); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-target_select.js b/devtools/client/inspector/animation/test/browser_animation_animation-target_select.js new file mode 100644 index 0000000000..970778c4c6 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_animation-target_select.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following selection feature related AnimationTarget component works: +// * select selected node by clicking on target node + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".multi", ".long"]); + const { animationInspector, panel } = await openAnimationInspector(); + + info("Check initial status"); + is( + panel.querySelectorAll(".animation-item").length, + 3, + "The length of animations should be 3. Two .multi animations and one .long animation" + ); + + info("Check selecting an animated node by clicking on the target node"); + await clickOnTargetNode(animationInspector, panel, 0); + assertNodeFront( + animationInspector.inspector.selection.nodeFront, + "DIV", + "ball multi" + ); + is( + panel.querySelectorAll(".animation-item").length, + 2, + "The length of animations should be 2" + ); + + info("Check if the both target nodes refer to the same node"); + await clickOnTargetNode(animationInspector, panel, 1); + assertNodeFront( + animationInspector.inspector.selection.nodeFront, + "DIV", + "ball multi" + ); + is( + panel.querySelectorAll(".animation-item").length, + 2, + "The length of animations should be 2" + ); +}); + +function assertNodeFront(nodeFront, tagName, classValue) { + is( + nodeFront.tagName, + tagName, + "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, + classValue, + "The highlighted node has the correct class" + ); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_animation-timeline-tick.js b/devtools/client/inspector/animation/test/browser_animation_animation-timeline-tick.js new file mode 100644 index 0000000000..c1bab42cc2 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_animation-timeline-tick.js @@ -0,0 +1,109 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following timeline tick items. +// * animation list header elements existence +// * tick labels elements existence +// * count and text of tick label elements changing by the sidebar width + +const TimeScale = require("resource://devtools/client/inspector/animation/utils/timescale.js"); +const { + findOptimalTimeInterval, +} = require("resource://devtools/client/inspector/animation/utils/utils.js"); + +// Should be kept in sync with TIME_GRADUATION_MIN_SPACING in +// AnimationTimeTickList component. +const TIME_GRADUATION_MIN_SPACING = 40; + +add_task(async function () { + await pushPref("devtools.inspector.three-pane-enabled", false); + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".end-delay", ".negative-delay"]); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + const timeScale = new TimeScale(animationInspector.state.animations); + + info("Checking animation list header element existence"); + const listContainerEl = panel.querySelector(".animation-list-container"); + const listHeaderEl = listContainerEl.querySelector(".devtools-toolbar"); + ok( + listHeaderEl, + "The header element should be in animation list container element" + ); + + info("Checking time tick item elements existence"); + await assertTickLabels(timeScale, listContainerEl); + const timelineTickItemLength = + listContainerEl.querySelectorAll(".tick-label").length; + + info("Checking timeline tick item elements after enlarge sidebar width"); + await setSidebarWidth("100%", inspector); + await assertTickLabels(timeScale, listContainerEl); + ok( + timelineTickItemLength < + listContainerEl.querySelectorAll(".tick-label").length, + "The timeline tick item elements should increase" + ); +}); + +/** + * Assert tick label's position and label. + * + * @param {TimeScale} - timeScale + * @param {Element} - listContainerEl + */ +async function assertTickLabels(timeScale, listContainerEl) { + const timelineTickListEl = listContainerEl.querySelector(".tick-labels"); + ok( + timelineTickListEl, + "The animation timeline tick list element should be in header" + ); + + const width = timelineTickListEl.offsetWidth; + const animationDuration = timeScale.getDuration(); + const minTimeInterval = + (TIME_GRADUATION_MIN_SPACING * animationDuration) / width; + const interval = findOptimalTimeInterval(minTimeInterval); + const shiftWidth = timeScale.zeroPositionTime % interval; + const expectedTickItem = + Math.ceil(animationDuration / interval) + (shiftWidth !== 0 ? 1 : 0); + + await waitUntil( + () => + timelineTickListEl.querySelectorAll(".tick-label").length === + expectedTickItem + ); + ok(true, "The expected number of timeline ticks were found"); + + const timelineTickItemEls = + timelineTickListEl.querySelectorAll(".tick-label"); + + info("Make sure graduations are evenly distributed and show the right times"); + for (const [index, tickEl] of timelineTickItemEls.entries()) { + const left = parseFloat(tickEl.style.marginInlineStart); + let expectedPos = + (((index - 1) * interval + shiftWidth) / animationDuration) * 100; + if (shiftWidth !== 0 && index === 0) { + expectedPos = 0; + } + is( + Math.round(left), + Math.round(expectedPos), + `Graduation ${index} is positioned correctly` + ); + + // Note that the distancetoRelativeTime and formatTime functions are tested + // separately in xpcshell test test_timeScale.js, so we assume that they + // work here. + const formattedTime = timeScale.formatTime( + timeScale.distanceToRelativeTime(expectedPos, width) + ); + is( + tickEl.textContent, + formattedTime, + `Graduation ${index} has the right text content` + ); + } +} diff --git a/devtools/client/inspector/animation/test/browser_animation_css-transition-with-playstate-idle.js b/devtools/client/inspector/animation/test/browser_animation_css-transition-with-playstate-idle.js new file mode 100644 index 0000000000..1970884623 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_css-transition-with-playstate-idle.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that animation inspector does not fail when rendering an animation that +// transitions from the playState "idle". + +const PAGE_URL = `data:text/html;charset=utf-8, +<!DOCTYPE html> +<html> +<head> + <style type="text/css"> + div { + opacity: 0; + transition-duration: 5000ms; + transition-property: opacity; + } + + div.visible { + opacity: 1; + } + </style> +</head> +<body> + <div>test</div> +</body> +</html>`; + +add_task(async function () { + const tab = await addTab(PAGE_URL); + const { animationInspector, panel } = await openAnimationInspector(); + + info("Toggle the visible class to start the animation"); + await toggleVisibleClass(tab); + + info("Wait until the scrubber is displayed"); + await waitUntil(() => panel.querySelector(".current-time-scrubber")); + const scrubberEl = panel.querySelector(".current-time-scrubber"); + + info("Wait until animations are paused"); + await waitUntilAnimationsPaused(animationInspector); + + // Check the initial position of the scrubber to detect the animation. + const scrubberX = scrubberEl.getBoundingClientRect().x; + + info("Toggle the visible class to start the animation"); + await toggleVisibleClass(tab); + + info("Wait until the scrubber starts moving"); + await waitUntil(() => scrubberEl.getBoundingClientRect().x != scrubberX); + + info("Wait until animations are paused"); + await waitUntilAnimationsPaused(animationInspector); + + // Query the scrubber element again to check that the UI is still rendered. + ok( + !!panel.querySelector(".current-time-scrubber"), + "The scrubber element is still rendered in the animation inspector panel" + ); +}); + +/** + * Local helper to toggle the "visible" class on the element with a transition defined. + */ +async function toggleVisibleClass(tab) { + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const win = content.wrappedJSObject; + win.document.querySelector("div").classList.toggle("visible"); + }); +} + +async function waitUntilAnimationsPaused(animationInspector) { + await waitUntil(() => { + const animations = animationInspector.state.animations; + return animations.every(animation => { + const state = animation.state.playState; + return state === "paused" || state === "finished"; + }); + }); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_current-time-label.js b/devtools/client/inspector/animation/test/browser_animation_current-time-label.js new file mode 100644 index 0000000000..0cff5b1f53 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_current-time-label.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following CurrentTimeLabel component: +// * element existence +// * label content at plural timing + +add_task(async function () { + await addTab(URL_ROOT + "doc_multi_timings.html"); + await removeAnimatedElementsExcept([".keyframes-easing-step"]); + const { animationInspector, panel } = await openAnimationInspector(); + + info("Checking current time label existence"); + const labelEl = panel.querySelector(".current-time-label"); + ok(labelEl, "current time label should exist"); + + info("Checking current time label content"); + const duration = animationInspector.state.timeScale.getDuration(); + clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + await waitUntilCurrentTimeChangedAt(animationInspector, duration * 0.5); + const targetAnimation = animationInspector.state.animations[0]; + assertLabelContent(labelEl, targetAnimation.state.currentTime); + + clickOnCurrentTimeScrubberController(animationInspector, panel, 0.2); + await waitUntilCurrentTimeChangedAt(animationInspector, duration * 0.2); + assertLabelContent(labelEl, targetAnimation.state.currentTime); + + info("Checking current time label content during running"); + // Resume + clickOnPauseResumeButton(animationInspector, panel); + const previousContent = labelEl.textContent; + + info("Wait until the time label changes"); + await waitFor(() => labelEl.textContent != previousContent); + isnot( + previousContent, + labelEl.textContent, + "Current time label should change" + ); +}); + +function assertLabelContent(labelEl, time) { + const expected = formatStopwatchTime(time); + is(labelEl.textContent, expected, `Content of label should be ${expected}`); +} + +function formatStopwatchTime(time) { + // Format falsy values as 0 + if (!time) { + return "00:00.000"; + } + + let milliseconds = parseInt(time % 1000, 10); + let seconds = parseInt((time / 1000) % 60, 10); + let minutes = parseInt(time / (1000 * 60), 10); + + const pad = (nb, max) => { + if (nb < max) { + return new Array((max + "").length - (nb + "").length + 1).join("0") + nb; + } + + return nb; + }; + + minutes = pad(minutes, 10); + seconds = pad(seconds, 10); + milliseconds = pad(milliseconds, 100); + + return `${minutes}:${seconds}.${milliseconds}`; +} diff --git a/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber-rtl.js b/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber-rtl.js new file mode 100644 index 0000000000..1da1c56e10 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber-rtl.js @@ -0,0 +1,18 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from current-time-scrubber_head.js */ + +// Test for CurrentTimeScrubber on RTL environment. + +add_task(async function () { + Services.scriptloader.loadSubScript( + CHROME_URL_ROOT + "current-time-scrubber_head.js", + this + ); + await pushPref("intl.l10n.pseudo", "bidi"); + // eslint-disable-next-line no-undef + await testCurrentTimeScrubber(true); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber-with-negative-delay.js b/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber-with-negative-delay.js new file mode 100644 index 0000000000..8b2e177079 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber-with-negative-delay.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether the most left position means negative current time. + +add_task(async function () { + await addTab(URL_ROOT + "doc_multi_timings.html"); + await removeAnimatedElementsExcept([ + ".cssanimation-normal", + ".delay-negative", + ]); + const { animationInspector, panel, inspector } = + await openAnimationInspector(); + + info("Checking scrubber controller existence"); + const controllerEl = panel.querySelector(".current-time-scrubber-area"); + ok(controllerEl, "scrubber controller should exist"); + + info("Checking the current time of most left scrubber position"); + const timeScale = animationInspector.state.timeScale; + clickOnCurrentTimeScrubberController(animationInspector, panel, 0); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + await waitUntilCurrentTimeChangedAt( + animationInspector, + -1 * timeScale.zeroPositionTime + ); + ok(true, "Current time is correct"); + + info("Select negative current time animation"); + await selectNode(".cssanimation-normal", inspector); + await waitUntilCurrentTimeChangedAt( + animationInspector, + -1 * timeScale.zeroPositionTime + ); + ok(true, "Current time is correct"); + + info("Back to 'body' and rewind the animation"); + await selectNode("body", inspector); + await waitUntil( + () => + panel.querySelectorAll(".animation-item").length === + animationInspector.state.animations.length + ); + clickOnRewindButton(animationInspector, panel); + await waitUntilCurrentTimeChangedAt(animationInspector, 0); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber.js b/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber.js new file mode 100644 index 0000000000..76cd42f282 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber.js @@ -0,0 +1,14 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from current-time-scrubber_head.js */ + +add_task(async function () { + Services.scriptloader.loadSubScript( + CHROME_URL_ROOT + "current-time-scrubber_head.js", + this + ); + await testCurrentTimeScrubber(); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber_each-different-creation-time-animations.js b/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber_each-different-creation-time-animations.js new file mode 100644 index 0000000000..85373b3295 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_current-time-scrubber_each-different-creation-time-animations.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether proper currentTime was set for each animations. + +const WAIT_TIME = 3000; + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".animated", ".still"]); + const { animationInspector, panel } = await openAnimationInspector(); + + info( + "Add an animation to make a situation which has different creation time" + ); + await wait(WAIT_TIME); + await setClassAttribute(animationInspector, ".still", "ball compositor-all"); + await waitUntil(() => panel.querySelectorAll(".animation-item").length === 2); + + info("Move the scrubber"); + clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + + info("Check existed animations have different currentTime"); + const animations = animationInspector.state.animations; + ok( + animations[0].state.currentTime + WAIT_TIME > + animations[1].state.currentTime, + `The currentTime of added animation shold be ${WAIT_TIME}ms less than ` + + "at least that currentTime of first animation" + ); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_empty_on_invalid_nodes.js b/devtools/client/inspector/animation/test/browser_animation_empty_on_invalid_nodes.js new file mode 100644 index 0000000000..d3fa9166a1 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_empty_on_invalid_nodes.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that the panel shows no animation data for invalid or not animated nodes + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".animated", ".long", ".still"]); + const { inspector, panel } = await openAnimationInspector(); + + info("Checking animation list and error message existence for a still node"); + const stillNode = await getNodeFront(".still", inspector); + await selectNode(stillNode, inspector); + + await waitUntil(() => panel.querySelector(".animation-error-message")); + ok( + true, + "Element which has animation-error-message class should exist for a still node" + ); + is( + panel.querySelector(".animation-error-message > p").textContent, + ANIMATION_L10N.getStr("panel.noAnimation"), + "The correct error message is displayed" + ); + ok( + !panel.querySelector(".animation-list"), + "Element which has animations class should not exist for a still node" + ); + + info( + "Show animations once to confirm if there is no animations on the comment node" + ); + await selectNode(".long", inspector); + await waitUntil(() => !panel.querySelector(".animation-error-message")); + + info("Checking animation list and error message existence for a text node"); + const commentNode = await inspector.walker.previousSibling(stillNode); + await selectNode(commentNode, inspector); + await waitUntil(() => panel.querySelector(".animation-error-message")); + ok( + panel.querySelector(".animation-error-message"), + "Element which has animation-error-message class should exist for a text node" + ); + ok( + !panel.querySelector(".animation-list"), + "Element which has animations class should not exist for a text node" + ); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_fission_switch-target.js b/devtools/client/inspector/animation/test/browser_animation_fission_switch-target.js new file mode 100644 index 0000000000..023428fad4 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_fission_switch-target.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the animation inspector works after switching targets. + +const PAGE_ON_CONTENT = `data:text/html;charset=utf-8, +<!DOCTYPE html> +<style type="text/css"> + div { + opacity: 0; + transition-duration: 5000ms; + transition-property: opacity; + } + + div:hover { + opacity: 1; + } +</style> +<div class="anim">animation</div> +`; +const PAGE_ON_MAIN = "about:networking"; + +add_task(async function () { + await pushPref("devtools.inspector.three-pane-enabled", false); + + info("Open a page that runs on the content process and has animations"); + const tab = await addTab(PAGE_ON_CONTENT); + const { animationInspector, inspector } = await openAnimationInspector(); + + info("Check the length of the initial animations of the content process"); + is( + animationInspector.state.animations.length, + 0, + "The length of the initial animation is correct" + ); + + info("Check whether the mutation on content process page is worked or not"); + await assertAnimationsMutation(tab, "div", animationInspector, 1); + + info("Load a page that runs on the main process"); + await navigateTo( + PAGE_ON_MAIN, + tab.linkedBrowser, + animationInspector, + inspector + ); + await waitUntil(() => animationInspector.state.animations.length === 0); + ok(true, "The animations are replaced"); + + info("Check whether the mutation on main process page is worked or not"); + await assertAnimationsMutation(tab, "#category-http", animationInspector, 1); + + info("Load a content process page again"); + await navigateTo( + PAGE_ON_CONTENT, + tab.linkedBrowser, + animationInspector, + inspector + ); + await waitUntil(() => animationInspector.state.animations.length === 0); + ok(true, "The animations are replaced again"); + + info("Check the mutation on content process again"); + await assertAnimationsMutation(tab, "div", animationInspector, 1); +}); + +async function assertAnimationsMutation( + tab, + selector, + animationInspector, + expectedAnimationCount +) { + await hover(tab, selector); + await waitUntil( + () => animationInspector.state.animations.length === expectedAnimationCount + ); + ok(true, "Animations mutation is worked"); +} + +async function navigateTo(uri, browser, animationInspector, inspector) { + const previousAnimationsFront = animationInspector.animationsFront; + const onReloaded = inspector.once("reloaded"); + const onUpdated = inspector.once("inspector-updated"); + BrowserTestUtils.loadURIString(browser, uri); + await waitUntil( + () => previousAnimationsFront !== animationInspector.animationsFront + ); + ok(true, "Target is switched correctly"); + await Promise.all([onReloaded, onUpdated]); +} + +async function hover(tab, selector) { + await SpecialPowers.spawn(tab.linkedBrowser, [selector], async s => { + const element = content.wrappedJSObject.document.querySelector(s); + InspectorUtils.addPseudoClassLock(element, ":hover", true); + }); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_indication-bar.js b/devtools/client/inspector/animation/test/browser_animation_indication-bar.js new file mode 100644 index 0000000000..829054178a --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_indication-bar.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether the indication bar of both scrubber and progress bar indicates correct +// progress after resizing animation inspector. + +add_task(async function () { + await pushPref("devtools.inspector.three-pane-enabled", false); + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".animated"]); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + + info("Checking timeline tick item elements after enlarge sidebar width"); + clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + await setSidebarWidth("100%", inspector); + assertPosition(".current-time-scrubber", panel, 0.5); + assertPosition(".keyframes-progress-bar", panel, 0.5); +}); + +/** + * Assert indication bar position. + * + * @param {String} indicationBarSelector + * @param {Element} panel + * @param {Number} expectedPositionRate + */ +function assertPosition(indicationBarSelector, panel, expectedPositionRate) { + const barEl = panel.querySelector(indicationBarSelector); + const parentEl = barEl.parentNode; + const rectBar = barEl.getBoundingClientRect(); + const rectParent = parentEl.getBoundingClientRect(); + const barX = rectBar.x + rectBar.width * 0.5 - rectParent.x; + const expectedPosition = rectParent.width * expectedPositionRate; + ok( + expectedPosition - 1 <= barX && barX <= expectedPosition + 1, + `Indication bar position should be approximately ${expectedPosition}` + ); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_infinity-duration_current-time-scrubber.js b/devtools/client/inspector/animation/test/browser_animation_infinity-duration_current-time-scrubber.js new file mode 100644 index 0000000000..8fae912d1f --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_infinity-duration_current-time-scrubber.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether the scrubber was working for even the animation of infinity duration. + +add_task(async function () { + await addTab(URL_ROOT + "doc_infinity_duration.html"); + await removeAnimatedElementsExcept([".infinity-delay-iteration-start"]); + const { animationInspector, panel } = await openAnimationInspector(); + + info("Set initial state"); + clickOnCurrentTimeScrubberController(animationInspector, panel, 0); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + const initialCurrentTime = + animationInspector.state.animations[0].state.currentTime; + + info("Check whether the animation currentTime was increased"); + clickOnCurrentTimeScrubberController(animationInspector, panel, 1); + await waitUntil( + () => + initialCurrentTime < + animationInspector.state.animations[0].state.currentTime + ); + ok(true, "currentTime should be increased"); + + info("Check whether the progress bar was moved"); + const areaEl = panel.querySelector(".keyframes-progress-bar-area"); + const barEl = areaEl.querySelector(".keyframes-progress-bar"); + const controllerBounds = areaEl.getBoundingClientRect(); + const barBounds = barEl.getBoundingClientRect(); + const barX = barBounds.x + barBounds.width / 2 - controllerBounds.x; + const expectedBarX = controllerBounds.width * 0.5; + ok( + Math.abs(barX - expectedBarX) < 1, + "Progress bar should indicate at progress of 0.5" + ); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_infinity-duration_summary-graph.js b/devtools/client/inspector/animation/test/browser_animation_infinity-duration_summary-graph.js new file mode 100644 index 0000000000..44343c3aa8 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_infinity-duration_summary-graph.js @@ -0,0 +1,147 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following summary graph with the animation which has infinity duration. +// * Tooltips +// * Graph path +// * Delay sign + +const TEST_DATA = [ + { + targetClass: "infinity", + expectedIterationPath: [ + { x: 0, y: 0 }, + { x: 200000, y: 0 }, + ], + expectedTooltip: { + duration: "\u221E", + }, + }, + { + targetClass: "infinity-delay-iteration-start", + expectedDelayPath: [ + { x: 0, y: 0 }, + { x: 100000, y: 0 }, + ], + expectedDelaySign: { + marginInlineStart: "0%", + width: "50%", + }, + expectedIterationPath: [ + { x: 100000, y: 50 }, + { x: 200000, y: 50 }, + ], + expectedTooltip: { + delay: "100s", + duration: "\u221E", + iterationStart: "0.5 (\u221E)", + }, + }, + { + targetClass: "limited", + expectedIterationPath: [ + { x: 0, y: 0 }, + { x: 100000, y: 100 }, + ], + expectedTooltip: { + duration: "100s", + }, + }, +]; + +add_task(async function () { + await addTab(URL_ROOT + "doc_infinity_duration.html"); + const { panel } = await openAnimationInspector(); + + for (const testData of TEST_DATA) { + const { + targetClass, + expectedDelayPath, + expectedDelaySign, + expectedIterationPath, + expectedTooltip, + } = testData; + + const animationItemEl = await findAnimationItemByTargetSelector( + panel, + `.${targetClass}` + ); + const summaryGraphEl = animationItemEl.querySelector( + ".animation-summary-graph" + ); + + info(`Check tooltip for the animation of .${targetClass}`); + assertTooltip(summaryGraphEl, expectedTooltip); + + if (expectedDelayPath) { + info(`Check delay path for the animation of .${targetClass}`); + assertDelayPath(summaryGraphEl, expectedDelayPath); + } + + if (expectedDelaySign) { + info(`Check delay sign for the animation of .${targetClass}`); + assertDelaySign(summaryGraphEl, expectedDelaySign); + } + + info(`Check iteration path for the animation of .${targetClass}`); + assertIterationPath(summaryGraphEl, expectedIterationPath); + } +}); + +function assertDelayPath(summaryGraphEl, expectedPath) { + assertPath( + summaryGraphEl, + ".animation-computed-timing-path .animation-delay-path", + expectedPath + ); +} + +function assertDelaySign(summaryGraphEl, expectedSign) { + const signEl = summaryGraphEl.querySelector(".animation-delay-sign"); + + is( + signEl.style.marginInlineStart, + expectedSign.marginInlineStart, + `marginInlineStart position should be ${expectedSign.marginInlineStart}` + ); + is( + signEl.style.width, + expectedSign.width, + `Width should be ${expectedSign.width}` + ); +} + +function assertIterationPath(summaryGraphEl, expectedPath) { + assertPath( + summaryGraphEl, + ".animation-computed-timing-path .animation-iteration-path", + expectedPath + ); +} + +function assertPath(summaryGraphEl, pathSelector, expectedPath) { + const pathEl = summaryGraphEl.querySelector(pathSelector); + assertPathSegments(pathEl, true, expectedPath); +} + +function assertTooltip(summaryGraphEl, expectedTooltip) { + const tooltip = summaryGraphEl.getAttribute("title"); + const { delay, duration, iterationStart } = expectedTooltip; + + if (delay) { + const expected = `Delay: ${delay}`; + ok(tooltip.includes(expected), `Tooltip should include '${expected}'`); + } + + if (duration) { + const expected = `Duration: ${duration}`; + ok(tooltip.includes(expected), `Tooltip should include '${expected}'`); + } + + if (iterationStart) { + const expected = `Iteration start: ${iterationStart}`; + ok(tooltip.includes(expected), `Tooltip should include '${expected}'`); + } +} diff --git a/devtools/client/inspector/animation/test/browser_animation_infinity-duration_tick-label.js b/devtools/client/inspector/animation/test/browser_animation_infinity-duration_tick-label.js new file mode 100644 index 0000000000..2a554267c4 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_infinity-duration_tick-label.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test of the content of tick label on timeline header +// with the animation which has infinity duration. + +add_task(async function () { + await addTab(URL_ROOT + "doc_infinity_duration.html"); + const { inspector, panel } = await openAnimationInspector(); + + info("Check the tick label content with limited duration animation"); + isnot( + panel.querySelector(".animation-list-container .tick-label:last-child") + .textContent, + "\u221E", + "The content should not be \u221E" + ); + + info("Check the tick label content with infinity duration animation only"); + await selectNode(".infinity", inspector); + await waitUntil( + () => + panel.querySelector(".animation-list-container .tick-label:last-child") + .textContent === "\u221E" + ); + ok(true, "The content should be \u221E"); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-01.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-01.js new file mode 100644 index 0000000000..b893626bda --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-01.js @@ -0,0 +1,164 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for ComputedValuePath of animations that consist by multi types of animated +// properties. + +requestLongerTimeout(2); + +const TEST_DATA = [ + { + targetClass: "multi-types", + properties: [ + { + name: "background-color", + computedValuePathClass: "color-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 0, y: 100 }, + { x: 1000, y: 100 }, + ], + expectedStopColors: [ + { offset: 0, color: "rgb(255, 0, 0)" }, + { offset: 1, color: "rgb(0, 255, 0)" }, + ], + }, + { + name: "background-repeat", + computedValuePathClass: "discrete-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 499.999, y: 0 }, + { x: 500, y: 100 }, + { x: 1000, y: 100 }, + ], + }, + { + name: "font-size", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 500, y: 50 }, + { x: 1000, y: 100 }, + ], + }, + { + name: "margin-left", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 500, y: 50 }, + { x: 1000, y: 100 }, + ], + }, + { + name: "opacity", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 500, y: 50 }, + { x: 1000, y: 100 }, + ], + }, + { + name: "text-align", + computedValuePathClass: "discrete-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 499.999, y: 0 }, + { x: 500, y: 100 }, + { x: 1000, y: 100 }, + ], + }, + { + name: "transform", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 500, y: 50 }, + { x: 1000, y: 100 }, + ], + }, + ], + }, + { + targetClass: "multi-types-reverse", + properties: [ + { + name: "background-color", + computedValuePathClass: "color-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 0, y: 100 }, + { x: 1000, y: 100 }, + ], + expectedStopColors: [ + { offset: 0, color: "rgb(0, 255, 0)" }, + { offset: 1, color: "rgb(255, 0, 0)" }, + ], + }, + { + name: "background-repeat", + computedValuePathClass: "discrete-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 499.999, y: 0 }, + { x: 500, y: 100 }, + { x: 1000, y: 100 }, + ], + }, + { + name: "font-size", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 100 }, + { x: 500, y: 50 }, + { x: 1000, y: 0 }, + ], + }, + { + name: "margin-left", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 100 }, + { x: 500, y: 50 }, + { x: 1000, y: 0 }, + ], + }, + { + name: "opacity", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 100 }, + { x: 500, y: 50 }, + { x: 1000, y: 0 }, + ], + }, + { + name: "text-align", + computedValuePathClass: "discrete-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 499.999, y: 0 }, + { x: 500, y: 100 }, + { x: 1000, y: 100 }, + ], + }, + { + name: "transform", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 100 }, + { x: 500, y: 50 }, + { x: 1000, y: 0 }, + ], + }, + ], + }, +]; + +add_task(async function () { + await testKeyframesGraphComputedValuePath(TEST_DATA); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-02.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-02.js new file mode 100644 index 0000000000..c36bd22628 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-02.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for ComputedValuePath of animations that consist by one animated property +// on complexed keyframes. + +requestLongerTimeout(2); + +const TEST_DATA = [ + { + targetClass: "steps-effect", + properties: [ + { + name: "opacity", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 250, y: 25 }, + { x: 500, y: 50 }, + { x: 750, y: 75 }, + { x: 1000, y: 100 }, + ], + }, + ], + }, + { + targetClass: "steps-jump-none-keyframe", + properties: [ + { + name: "opacity", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 199, y: 0 }, + { x: 200, y: 25 }, + { x: 399, y: 25 }, + { x: 400, y: 50 }, + { x: 599, y: 50 }, + { x: 600, y: 75 }, + { x: 799, y: 75 }, + { x: 800, y: 100 }, + { x: 1000, y: 100 }, + ], + }, + ], + }, + { + targetClass: "narrow-offsets", + properties: [ + { + name: "opacity", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 100, y: 100 }, + { x: 110, y: 100 }, + { x: 114.9, y: 100 }, + { x: 115, y: 50 }, + { x: 129.9, y: 50 }, + { x: 130, y: 0 }, + { x: 1000, y: 100 }, + ], + }, + ], + }, + { + targetClass: "duplicate-offsets", + properties: [ + { + name: "opacity", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 100 }, + { x: 250, y: 100 }, + { x: 499, y: 100 }, + { x: 500, y: 100 }, + { x: 500, y: 0 }, + { x: 750, y: 50 }, + { x: 1000, y: 100 }, + ], + }, + ], + }, +]; + +add_task(async function () { + await testKeyframesGraphComputedValuePath(TEST_DATA); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-03.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-03.js new file mode 100644 index 0000000000..957a693a31 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path-03.js @@ -0,0 +1,190 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for ComputedValuePath of animations that consist by multi types of animated +// properties on complexed keyframes. + +requestLongerTimeout(2); + +const TEST_DATA = [ + { + targetClass: "middle-keyframe", + properties: [ + { + name: "background-color", + computedValuePathClass: "color-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 0, y: 100 }, + { x: 500, y: 100 }, + { x: 1000, y: 100 }, + ], + expectedStopColors: [ + { offset: 0, color: "rgb(255, 0, 0)" }, + { offset: 0.5, color: "rgb(0, 0, 255)" }, + { offset: 1, color: "rgb(0, 255, 0)" }, + ], + }, + { + name: "background-repeat", + computedValuePathClass: "discrete-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 249.999, y: 0 }, + { x: 250, y: 100 }, + { x: 749.999, y: 100 }, + { x: 750, y: 0 }, + { x: 1000, y: 0 }, + ], + }, + { + name: "font-size", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 250, y: 50 }, + { x: 500, y: 100 }, + { x: 750, y: 50 }, + { x: 1000, y: 0 }, + ], + }, + { + name: "margin-left", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 250, y: 50 }, + { x: 500, y: 100 }, + { x: 750, y: 50 }, + { x: 1000, y: 0 }, + ], + }, + { + name: "opacity", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 250, y: 50 }, + { x: 500, y: 100 }, + { x: 750, y: 50 }, + { x: 1000, y: 0 }, + ], + }, + { + name: "text-align", + computedValuePathClass: "discrete-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 249.999, y: 0 }, + { x: 250, y: 100 }, + { x: 749.999, y: 100 }, + { x: 750, y: 0 }, + { x: 1000, y: 0 }, + ], + }, + { + name: "transform", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 250, y: 50 }, + { x: 500, y: 100 }, + { x: 750, y: 50 }, + { x: 1000, y: 0 }, + ], + }, + ], + }, + { + targetClass: "steps-keyframe", + properties: [ + { + name: "background-color", + computedValuePathClass: "color-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 0, y: 100 }, + { x: 500, y: 100 }, + { x: 1000, y: 100 }, + ], + expectedStopColors: [ + { offset: 0, color: "rgb(255, 0, 0)" }, + { offset: 0.499, color: "rgb(255, 0, 0)" }, + { offset: 0.5, color: "rgb(128, 128, 0)" }, + { offset: 0.999, color: "rgb(128, 128, 0)" }, + { offset: 1, color: "rgb(0, 255, 0)" }, + ], + }, + { + name: "background-repeat", + computedValuePathClass: "discrete-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 499.999, y: 0 }, + { x: 500, y: 100 }, + { x: 1000, y: 100 }, + ], + }, + { + name: "font-size", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 500, y: 0 }, + { x: 500, y: 50 }, + { x: 1000, y: 50 }, + { x: 1000, y: 100 }, + ], + }, + { + name: "margin-left", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 499.999, y: 0 }, + { x: 500, y: 50 }, + { x: 999.999, y: 50 }, + { x: 1000, y: 100 }, + ], + }, + { + name: "opacity", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 499.999, y: 0 }, + { x: 500, y: 50 }, + { x: 999.999, y: 50 }, + { x: 1000, y: 100 }, + ], + }, + { + name: "text-align", + computedValuePathClass: "discrete-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 499.999, y: 0 }, + { x: 500, y: 100 }, + { x: 1000, y: 100 }, + ], + }, + { + name: "transform", + computedValuePathClass: "distance-path", + expectedPathSegments: [ + { x: 0, y: 0 }, + { x: 500, y: 0 }, + { x: 500, y: 50 }, + { x: 1000, y: 50 }, + { x: 1000, y: 100 }, + ], + }, + ], + }, +]; + +add_task(async function () { + await testKeyframesGraphComputedValuePath(TEST_DATA); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path_easing-hint.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path_easing-hint.js new file mode 100644 index 0000000000..b95c8d5fe4 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_computed-value-path_easing-hint.js @@ -0,0 +1,380 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following easing hint in ComputedValuePath. +// * element existence +// * path segments +// * hint text + +const TEST_DATA = [ + { + targetClass: "no-easing", + properties: [ + { + name: "opacity", + expectedHints: [ + { + hint: "linear", + path: [ + { x: 0, y: 100 }, + { x: 500, y: 50 }, + { x: 1000, y: 0 }, + ], + }, + ], + }, + ], + }, + { + targetClass: "effect-easing", + properties: [ + { + name: "opacity", + expectedHints: [ + { + hint: "linear", + path: [ + { x: 0, y: 100 }, + { x: 199, y: 81 }, + { x: 200, y: 80 }, + { x: 399, y: 61 }, + { x: 400, y: 60 }, + { x: 599, y: 41 }, + { x: 600, y: 40 }, + { x: 799, y: 21 }, + { x: 800, y: 20 }, + { x: 1000, y: 0 }, + ], + }, + ], + }, + ], + }, + { + targetClass: "keyframe-easing", + properties: [ + { + name: "opacity", + expectedHints: [ + { + hint: "steps(2)", + path: [ + { x: 0, y: 100 }, + { x: 499, y: 100 }, + { x: 500, y: 50 }, + { x: 999, y: 50 }, + { x: 1000, y: 0 }, + ], + }, + ], + }, + ], + }, + { + targetClass: "both-easing", + properties: [ + { + name: "margin-left", + expectedHints: [ + { + hint: "steps(1)", + path: [ + { x: 0, y: 0 }, + { x: 999, y: 0 }, + { x: 1000, y: 100 }, + ], + }, + ], + }, + { + name: "opacity", + expectedHints: [ + { + hint: "steps(2)", + path: [ + { x: 0, y: 100 }, + { x: 499, y: 100 }, + { x: 500, y: 50 }, + { x: 999, y: 50 }, + { x: 1000, y: 0 }, + ], + }, + ], + }, + ], + }, + { + targetClass: "narrow-keyframes", + properties: [ + { + name: "opacity", + expectedHints: [ + { + hint: "linear", + path: [ + { x: 0, y: 0 }, + { x: 100, y: 100 }, + ], + }, + { + hint: "steps(1)", + path: [ + { x: 129, y: 100 }, + { x: 130, y: 0 }, + ], + }, + { + hint: "linear", + path: [ + { x: 130, y: 0 }, + { x: 1000, y: 100 }, + ], + }, + ], + }, + ], + }, + { + targetClass: "duplicate-keyframes", + properties: [ + { + name: "opacity", + expectedHints: [ + { + hint: "linear", + path: [ + { x: 0, y: 0 }, + { x: 500, y: 100 }, + ], + }, + { + hint: "", + path: [ + { x: 500, y: 100 }, + { x: 500, y: 0 }, + ], + }, + { + hint: "steps(1)", + path: [ + { x: 500, y: 0 }, + { x: 999, y: 0 }, + { x: 1000, y: 100 }, + ], + }, + ], + }, + ], + }, + { + targetClass: "color-keyframes", + properties: [ + { + name: "color", + expectedHints: [ + { + hint: "ease-in", + rect: { + x: 0, + height: 100, + width: 400, + }, + }, + { + hint: "ease-out", + rect: { + x: 400, + height: 100, + width: 600, + }, + }, + ], + }, + ], + }, + { + targetClass: "jump-start", + properties: [ + { + name: "opacity", + expectedHints: [ + { + hint: "steps(2, jump-start)", + path: [ + { x: 0, y: 50 }, + { x: 499, y: 50 }, + { x: 500, y: 0 }, + { x: 1000, y: 0 }, + ], + }, + ], + }, + ], + }, + { + targetClass: "jump-end", + properties: [ + { + name: "opacity", + expectedHints: [ + { + hint: "steps(2)", + path: [ + { x: 0, y: 100 }, + { x: 499, y: 100 }, + { x: 500, y: 50 }, + { x: 999, y: 50 }, + { x: 1000, y: 0 }, + ], + }, + ], + }, + ], + }, + { + targetClass: "jump-both", + properties: [ + { + name: "opacity", + expectedHints: [ + { + hint: "steps(3, jump-both)", + path: [ + { x: 0, y: 75 }, + { x: 330, y: 75 }, + { x: 340, y: 50 }, + { x: 660, y: 50 }, + { x: 670, y: 25 }, + { x: 999, y: 25 }, + { x: 1000, y: 0 }, + ], + }, + ], + }, + ], + }, +]; + +// Prevent test timeout's on windows code coverage: Bug 1470757 +requestLongerTimeout(2); + +add_task(async function () { + await addTab(URL_ROOT + "doc_multi_easings.html"); + await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`)); + const { animationInspector, panel } = await openAnimationInspector(); + + for (const { properties, targetClass } of TEST_DATA) { + info(`Checking keyframes graph for ${targetClass}`); + const onDetailRendered = animationInspector.once( + "animation-keyframes-rendered" + ); + await clickOnAnimationByTargetSelector( + animationInspector, + panel, + `.${targetClass}` + ); + await onDetailRendered; + + for (const { name, expectedHints } of properties) { + const testTarget = `${name} in ${targetClass}`; + info(`Checking easing hint for ${testTarget}`); + info(`Checking easing hint existence for ${testTarget}`); + const hintEls = panel.querySelectorAll(`.${name} .hint`); + is( + hintEls.length, + expectedHints.length, + `Count of easing hint elements of ${testTarget} ` + + `should be ${expectedHints.length}` + ); + + for (let i = 0; i < expectedHints.length; i++) { + const hintTarget = `hint[${i}] of ${testTarget}`; + + info(`Checking ${hintTarget}`); + const hintEl = hintEls[i]; + const expectedHint = expectedHints[i]; + + info(`Checking <title> in ${hintTarget}`); + const titleEl = hintEl.querySelector("title"); + ok(titleEl, `<title> element in ${hintTarget} should be existence`); + is( + titleEl.textContent, + expectedHint.hint, + `Content of <title> in ${hintTarget} should be ${expectedHint.hint}` + ); + + let interactionEl = null; + let displayedEl = null; + if (expectedHint.path) { + info(`Checking <path> in ${hintTarget}`); + interactionEl = hintEl.querySelector("path"); + displayedEl = interactionEl; + ok( + interactionEl, + `The <path> element in ${hintTarget} should be existence` + ); + assertPathSegments(interactionEl, false, expectedHint.path); + } else { + info(`Checking <rect> in ${hintTarget}`); + interactionEl = hintEl.querySelector("rect"); + displayedEl = hintEl.querySelector("line"); + ok( + interactionEl, + `The <rect> element in ${hintTarget} should be existence` + ); + is( + parseInt(interactionEl.getAttribute("x"), 10), + expectedHint.rect.x, + `x of <rect> in ${hintTarget} should be ${expectedHint.rect.x}` + ); + is( + parseInt(interactionEl.getAttribute("width"), 10), + expectedHint.rect.width, + `width of <rect> in ${hintTarget} should be ${expectedHint.rect.width}` + ); + } + + info(`Checking interaction for ${hintTarget}`); + interactionEl.scrollIntoView(false); + const win = hintEl.ownerGlobal; + // Mouse over the pathEl. + ok( + isStrokeChangedByMouseOver(interactionEl, displayedEl, win), + `stroke-opacity of hintEl for ${hintTarget} should be 1 ` + + "while mouse is over the element" + ); + // Mouse out from pathEl. + EventUtils.synthesizeMouse( + panel.querySelector(".animation-toolbar"), + 0, + 0, + { type: "mouseover" }, + win + ); + is( + parseInt(win.getComputedStyle(displayedEl).strokeOpacity, 10), + 0, + `stroke-opacity of hintEl for ${hintTarget} should be 0 ` + + "while mouse is out from the element" + ); + } + } + } +}); + +function isStrokeChangedByMouseOver(mouseoverEl, displayedEl, win) { + const boundingBox = mouseoverEl.getBoundingClientRect(); + const x = boundingBox.width / 2; + + for (let y = 0; y < boundingBox.height; y++) { + EventUtils.synthesizeMouse(mouseoverEl, x, y, { type: "mouseover" }, win); + + if (win.getComputedStyle(displayedEl).strokeOpacity == 1) { + return true; + } + } + + return false; +} diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_keyframe-marker-rtl.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_keyframe-marker-rtl.js new file mode 100644 index 0000000000..c90b231f0a --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_keyframe-marker-rtl.js @@ -0,0 +1,14 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + Services.scriptloader.loadSubScript( + CHROME_URL_ROOT + "keyframes-graph_keyframe-marker_head.js", + this + ); + await pushPref("intl.l10n.pseudo", "bidi"); + // eslint-disable-next-line no-undef + await testKeyframesGraphKeyframesMarker(); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_keyframe-marker.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_keyframe-marker.js new file mode 100644 index 0000000000..a19c2993f2 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_keyframe-marker.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + Services.scriptloader.loadSubScript( + CHROME_URL_ROOT + "keyframes-graph_keyframe-marker_head.js", + this + ); + // eslint-disable-next-line no-undef + await testKeyframesGraphKeyframesMarker(); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_special-colors.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_special-colors.js new file mode 100644 index 0000000000..6f46b44332 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-graph_special-colors.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_DATA = [ + { + propertyName: "caret-color", + expectedMarkers: ["auto", "rgb(0, 255, 0)"], + }, + { + propertyName: "scrollbar-color", + expectedMarkers: ["rgb(0, 255, 0) rgb(255, 0, 0)", "auto"], + }, +]; + +// Test for animatable property which can specify the non standard CSS color value. +add_task(async function () { + await addTab(URL_ROOT + "doc_special_colors.html"); + const { panel } = await openAnimationInspector(); + + for (const { propertyName, expectedMarkers } of TEST_DATA) { + const animatedPropertyEl = panel.querySelector(`.${propertyName}`); + ok(animatedPropertyEl, `Animated property ${propertyName} exists`); + + const markerEls = animatedPropertyEl.querySelectorAll( + ".keyframe-marker-item" + ); + is( + markerEls.length, + expectedMarkers.length, + `The length of keyframe markers should ${expectedMarkers.length}` + ); + for (let i = 0; i < expectedMarkers.length; i++) { + const actualTitle = markerEls[i].title; + const expectedTitle = expectedMarkers[i]; + is(actualTitle, expectedTitle, `Value of keyframes[${i}] is correct`); + } + } +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-progress-bar.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-progress-bar.js new file mode 100644 index 0000000000..a7051d9a01 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-progress-bar.js @@ -0,0 +1,103 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following KeyframesProgressBar: +// * element existence +// * progress bar position in multi effect timings +// * progress bar position after changing playback rate +// * progress bar position when select another animation + +requestLongerTimeout(3); + +const TEST_DATA = [ + { + targetClass: "cssanimation-linear", + scrubberPositions: [0, 0.25, 0.5, 0.75, 1], + expectedPositions: [0, 0.25, 0.5, 0.75, 0], + }, + { + targetClass: "easing-step", + scrubberPositions: [0, 0.49, 0.5, 0.99], + expectedPositions: [0, 0, 0.5, 0.5], + }, + { + targetClass: "delay-positive", + scrubberPositions: [0, 0.33, 0.5], + expectedPositions: [0, 0, 0.25], + }, + { + targetClass: "delay-negative", + scrubberPositions: [0, 0.49, 0.5, 0.75], + expectedPositions: [0, 0, 0.5, 0.75], + }, + { + targetClass: "enddelay-positive", + scrubberPositions: [0, 0.66, 0.67, 0.99], + expectedPositions: [0, 0.99, 0, 0], + }, + { + targetClass: "enddelay-negative", + scrubberPositions: [0, 0.49, 0.5, 0.99], + expectedPositions: [0, 0.49, 0, 0], + }, + { + targetClass: "direction-reverse-with-iterations-infinity", + scrubberPositions: [0, 0.25, 0.5, 0.75, 1], + expectedPositions: [1, 0.75, 0.5, 0.25, 1], + }, + { + targetClass: "fill-both-width-delay-iterationstart", + scrubberPositions: [0, 0.33, 0.66, 0.833, 1], + expectedPositions: [0.5, 0.5, 0.99, 0.25, 0.5], + }, +]; + +add_task(async function () { + await addTab(URL_ROOT + "doc_multi_timings.html"); + await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`)); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + + info("Checking progress bar position in multi effect timings"); + + for (const testdata of TEST_DATA) { + const { targetClass, scrubberPositions, expectedPositions } = testdata; + + info(`Checking progress bar position for ${targetClass}`); + const onDetailRendered = animationInspector.once( + "animation-keyframes-rendered" + ); + await selectNode(`.${targetClass}`, inspector); + await onDetailRendered; + + info("Checking progress bar existence"); + const areaEl = panel.querySelector(".keyframes-progress-bar-area"); + ok(areaEl, "progress bar area should exist"); + const barEl = areaEl.querySelector(".keyframes-progress-bar"); + ok(barEl, "progress bar should exist"); + + for (let i = 0; i < scrubberPositions.length; i++) { + info(`Scrubber position is ${scrubberPositions[i]}`); + clickOnCurrentTimeScrubberController( + animationInspector, + panel, + scrubberPositions[i] + ); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + assertPosition(barEl, areaEl, expectedPositions[i], animationInspector); + } + } +}); + +function assertPosition(barEl, areaEl, expectedRate, animationInspector) { + const controllerBounds = areaEl.getBoundingClientRect(); + const barBounds = barEl.getBoundingClientRect(); + const barX = barBounds.x + barBounds.width / 2 - controllerBounds.x; + const expected = controllerBounds.width * expectedRate; + ok( + expected - 1 < barX && barX < expected + 1, + `Position should apploximately be ${expected} (x of bar is ${barX})` + ); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_keyframes-progress-bar_after-resuming.js b/devtools/client/inspector/animation/test/browser_animation_keyframes-progress-bar_after-resuming.js new file mode 100644 index 0000000000..8b91026799 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_keyframes-progress-bar_after-resuming.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether keyframes progress bar moves correctly after resuming the animation. + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".animated"]); + const { animationInspector, panel } = await openAnimationInspector(); + + const scrubberPositions = [0, 0.25, 0.5, 0.75]; + const expectedPositions = [0, 0.25, 0.5, 0.75]; + + info("Check whether the keyframes progress bar position was correct"); + await assertPosition( + panel, + scrubberPositions, + expectedPositions, + animationInspector + ); + + info( + "Check whether the keyframes progress bar position was correct " + + "after a bit time passed and resuming" + ); + await wait(500); + clickOnPauseResumeButton(animationInspector, panel); + await assertPosition( + panel, + scrubberPositions, + expectedPositions, + animationInspector + ); +}); + +async function assertPosition( + panel, + scrubberPositions, + expectedPositions, + animationInspector +) { + const areaEl = panel.querySelector(".keyframes-progress-bar-area"); + const barEl = areaEl.querySelector(".keyframes-progress-bar"); + const controllerBounds = areaEl.getBoundingClientRect(); + + for (let i = 0; i < scrubberPositions.length; i++) { + info(`Scrubber position is ${scrubberPositions[i]}`); + clickOnCurrentTimeScrubberController( + animationInspector, + panel, + scrubberPositions[i] + ); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + const barBounds = barEl.getBoundingClientRect(); + const barX = barBounds.x + barBounds.width / 2 - controllerBounds.x; + const expected = controllerBounds.width * expectedPositions[i]; + ok( + expected - 1 < barX && barX < expected + 1, + `Position should apploximately be ${expected} (x of bar is ${barX})` + ); + } +} diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_adjust-time-with-playback-rate.js b/devtools/client/inspector/animation/test/browser_animation_logic_adjust-time-with-playback-rate.js new file mode 100644 index 0000000000..ad356cadd2 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_logic_adjust-time-with-playback-rate.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test adjusting the created time with different playback rate of animation. + +add_task(async function () { + await addTab(URL_ROOT + "doc_custom_playback_rate.html"); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + + info( + "Pause the all animation and set current time to middle in order to check " + + "the adjusting time" + ); + clickOnPauseResumeButton(animationInspector, panel); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5); + + info("Check the created times of all animation are same"); + checkAdjustingTheTime( + animationInspector.state.animations[0].state, + animationInspector.state.animations[1].state + ); + + info("Change the playback rate to x10 after selecting '.div2'"); + await selectNode(".div2", inspector); + await waitUntil(() => panel.querySelectorAll(".animation-item").length === 1); + await changePlaybackRateSelector(animationInspector, panel, 10); + + info("Check each adjusted result of animations after selecting 'body' again"); + await selectNode("body", inspector); + await waitUntil(() => panel.querySelectorAll(".animation-item").length === 2); + + checkAdjustingTheTime( + animationInspector.state.animations[0].state, + animationInspector.state.animations[1].state + ); + + await waitUntil( + () => animationInspector.state.animations[0].state.currentTime === 50000 + ); + ok(true, "The current time of '.div1' animation is 50%"); + + await waitUntil( + () => animationInspector.state.animations[1].state.currentTime === 50000 + ); + ok(true, "The current time of '.div2' animation is 50%"); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_adjust-time.js b/devtools/client/inspector/animation/test/browser_animation_logic_adjust-time.js new file mode 100644 index 0000000000..44769ea055 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_logic_adjust-time.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test adjusting the created time with different current times of animation. + +add_task(async function () { + await addTab(URL_ROOT + "doc_custom_playback_rate.html"); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + + info( + "Pause the all animation and set current time to middle time in order to " + + "check the adjusting time" + ); + clickOnPauseResumeButton(animationInspector, panel); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5); + + info("Check the created times of all animation are same"); + checkAdjustingTheTime( + animationInspector.state.animations[0].state, + animationInspector.state.animations[1].state + ); + + info("Change the current time to 75% after selecting '.div2'"); + await selectNode(".div2", inspector); + await waitUntil(() => panel.querySelectorAll(".animation-item").length === 1); + clickOnCurrentTimeScrubberController(animationInspector, panel, 0.75); + + info("Check each adjusted result of animations after selecting 'body' again"); + await selectNode("body", inspector); + await waitUntil(() => panel.querySelectorAll(".animation-item").length === 2); + + checkAdjustingTheTime( + animationInspector.state.animations[0].state, + animationInspector.state.animations[1].state + ); + is( + animationInspector.state.animations[0].state.currentTime, + 50000, + "The current time of '.div1' animation is 50%" + ); + is( + animationInspector.state.animations[1].state.currentTime, + 75000, + "The current time of '.div2' animation is 75%" + ); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_auto-stop.js b/devtools/client/inspector/animation/test/browser_animation_logic_auto-stop.js new file mode 100644 index 0000000000..fdf1867ffa --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_logic_auto-stop.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Animation inspector makes the current time to stop +// after end of animation duration except iterations infinity. +// Test followings: +// * state of animations and UI components after end of animation duration +// * state of animations and UI components after end of animation duration +// but iteration count is infinity + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".compositor-all", ".long"]); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + + info("Checking state after end of animation duration"); + await selectNode(".long", inspector); + await waitUntil(() => panel.querySelectorAll(".animation-item").length === 1); + const pixelsData = getDurationAndRate(animationInspector, panel, 5); + clickOnCurrentTimeScrubberController( + animationInspector, + panel, + 1 - pixelsData.rate + ); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + clickOnPauseResumeButton(animationInspector, panel); + await assertStates(animationInspector, panel, false); + + info( + "Checking state after end of animation duration and infinity iterations" + ); + clickOnPauseResumeButton(animationInspector, panel); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + await selectNode(".compositor-all", inspector); + await waitUntil(() => panel.querySelectorAll(".animation-item").length === 1); + clickOnCurrentTimeScrubberController(animationInspector, panel, 1); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + clickOnPauseResumeButton(animationInspector, panel); + await assertStates(animationInspector, panel, true); +}); + +async function assertStates(animationInspector, panel, shouldRunning) { + const buttonEl = panel.querySelector(".pause-resume-button"); + const labelEl = panel.querySelector(".current-time-label"); + const scrubberEl = panel.querySelector(".current-time-scrubber"); + + const previousLabelContent = labelEl.textContent; + const previousScrubberX = scrubberEl.getBoundingClientRect().x; + + await waitUntilAnimationsPlayState( + animationInspector, + shouldRunning ? "running" : "paused" + ); + + const currentLabelContent = labelEl.textContent; + const currentScrubberX = scrubberEl.getBoundingClientRect().x; + + if (shouldRunning) { + isnot( + previousLabelContent, + currentLabelContent, + "Current time label content should change" + ); + isnot( + previousScrubberX, + currentScrubberX, + "Current time scrubber position should change" + ); + ok( + !buttonEl.classList.contains("paused"), + "State of button should be running" + ); + assertAnimationsRunning(animationInspector); + } else { + is( + previousLabelContent, + currentLabelContent, + "Current time label Content should not change" + ); + is( + previousScrubberX, + currentScrubberX, + "Current time scrubber position should not change" + ); + ok( + buttonEl.classList.contains("paused"), + "State of button should be paused" + ); + assertAnimationsPausing(animationInspector); + } +} diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_avoid-updating-during-hiding.js b/devtools/client/inspector/animation/test/browser_animation_logic_avoid-updating-during-hiding.js new file mode 100644 index 0000000000..cfdd111ec9 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_logic_avoid-updating-during-hiding.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Animation inspector should not update when hidden. +// Test for followings: +// * whether the UIs update after selecting another inspector +// * whether the UIs update after selecting another tool +// * whether the UIs update after selecting animation inspector again + +add_task(async function () { + info( + "Switch to 2 pane inspector to see if the animation only refreshes when visible" + ); + await pushPref("devtools.inspector.three-pane-enabled", false); + await addTab(URL_ROOT + "doc_custom_playback_rate.html"); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + + info("Checking the UIs update after selecting another inspector"); + await selectNode("head", inspector); + inspector.sidebar.select("ruleview"); + await selectNode("div", inspector); + await waitUntil(() => !animationInspector.state.animations.length); + ok(true, "Should not update after selecting another inspector"); + + await selectAnimationInspector(inspector); + await waitUntil(() => animationInspector.state.animations.length); + ok(true, "Should update after selecting animation inspector"); + + await assertCurrentTimeUpdated(animationInspector, panel, true); + inspector.sidebar.select("ruleview"); + is( + animationInspector.state.animations.length, + 1, + "Should not update after selecting another inspector again" + ); + await assertCurrentTimeUpdated(animationInspector, panel, false); + + info("Checking the UIs update after selecting another tool"); + await selectAnimationInspector(inspector); + await selectNode("head", inspector); + await waitUntil(() => !animationInspector.state.animations.length); + await inspector.toolbox.selectTool("webconsole"); + await selectNode("div", inspector); + is( + animationInspector.state.animations.length, + 0, + "Should not update after selecting another tool" + ); + await selectAnimationInspector(inspector); + await waitUntil(() => animationInspector.state.animations.length); + is( + animationInspector.state.animations.length, + 1, + "Should update after selecting animation inspector" + ); + await assertCurrentTimeUpdated(animationInspector, panel, true); + await inspector.toolbox.selectTool("webconsole"); + await waitUntil(() => animationInspector.state.animations.length); + is( + animationInspector.state.animations.length, + 1, + "Should not update after selecting another tool again" + ); + await assertCurrentTimeUpdated(animationInspector, panel, false); +}); + +async function assertCurrentTimeUpdated( + animationInspector, + panel, + shouldRunning +) { + let count = 0; + + const listener = () => { + count++; + }; + + animationInspector.addAnimationsCurrentTimeListener(listener); + await new Promise(resolve => + panel.ownerGlobal.requestAnimationFrame(resolve) + ); + animationInspector.removeAnimationsCurrentTimeListener(listener); + + if (shouldRunning) { + isnot(count, 0, "Should forward current time"); + } else { + is(count, 0, "Should not forward current time"); + } +} diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_created-time.js b/devtools/client/inspector/animation/test/browser_animation_logic_created-time.js new file mode 100644 index 0000000000..59e0f4df52 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_logic_created-time.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether the created time of animation unchanged even if change node. + +add_task(async function () { + await addTab(URL_ROOT + "doc_custom_playback_rate.html"); + const { animationInspector, inspector } = await openAnimationInspector(); + + info("Check both the created time of animation are same"); + const baseCreatedTime = + animationInspector.state.animations[0].state.createdTime; + is( + animationInspector.state.animations[1].state.createdTime, + baseCreatedTime, + "Both created time of animations should be same" + ); + + info("Check created time after selecting '.div1'"); + await selectNode(".div1", inspector); + await waitUntil( + () => + animationInspector.state.animations[0].state.createdTime === + baseCreatedTime + ); + ok( + true, + "The created time of animation on element of .div1 should unchanged" + ); + + info("Check created time after selecting '.div2'"); + await selectNode(".div2", inspector); + await waitUntil( + () => + animationInspector.state.animations[0].state.createdTime === + baseCreatedTime + ); + ok( + true, + "The created time of animation on element of .div2 should unchanged" + ); + + info("Check created time after selecting 'body' again"); + await selectNode("body", inspector); + is( + animationInspector.state.animations[0].state.createdTime, + baseCreatedTime, + "The created time of animation[0] should unchanged" + ); + is( + animationInspector.state.animations[1].state.createdTime, + baseCreatedTime, + "The created time of animation[1] should unchanged" + ); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_mutations.js b/devtools/client/inspector/animation/test/browser_animation_logic_mutations.js new file mode 100644 index 0000000000..56228a60c2 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_logic_mutations.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following mutations: +// * add animation +// * remove animation +// * modify animation + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([ + ".compositor-all", + ".compositor-notall", + ".no-compositor", + ".still", + ]); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + + info("Checking the mutation for add an animation"); + const originalAnimationCount = animationInspector.state.animations.length; + await setClassAttribute(animationInspector, ".still", "ball no-compositor"); + await waitUntil( + () => + animationInspector.state.animations.length === originalAnimationCount + 1 + ); + ok(true, "Count of animation should be plus one to original count"); + + info( + "Checking added animation existence even the animation name is duplicated" + ); + is( + getAnimationNameCount(panel, "no-compositor"), + 2, + "Count of animation should be plus one to original count" + ); + + info("Checking the mutation for remove an animation"); + await setClassAttribute( + animationInspector, + ".compositor-notall", + "ball still" + ); + await waitUntil( + () => animationInspector.state.animations.length === originalAnimationCount + ); + ok( + true, + "Count of animation should be same to original count since we remove an animation" + ); + + info("Checking the mutation for modify an animation"); + await selectNode(".compositor-all", inspector); + await setStyle( + animationInspector, + ".compositor-all", + "animationDuration", + "100s" + ); + await setStyle( + animationInspector, + ".compositor-all", + "animationIterationCount", + 1 + ); + const summaryGraphPathEl = getSummaryGraphPathElement( + panel, + "compositor-all" + ); + await waitUntil(() => summaryGraphPathEl.viewBox.baseVal.width === 100000); + ok( + true, + "Width of summary graph path should be 100000 " + + "after modifing the duration and iteration count" + ); + await setStyle( + animationInspector, + ".compositor-all", + "animationDelay", + "100s" + ); + await waitUntil(() => summaryGraphPathEl.viewBox.baseVal.width === 200000); + ok( + true, + "Width of summary graph path should be 200000 after modifing the delay" + ); + ok( + summaryGraphPathEl.parentElement.querySelector(".animation-delay-sign"), + "Delay sign element shoud exist" + ); +}); + +function getAnimationNameCount(panel, animationName) { + return [...panel.querySelectorAll(".animation-name")].reduce( + (count, element) => + element.textContent === animationName ? count + 1 : count, + 0 + ); +} + +function getSummaryGraphPathElement(panel, animationName) { + for (const animationNameEl of panel.querySelectorAll(".animation-name")) { + if (animationNameEl.textContent === animationName) { + return animationNameEl + .closest(".animation-summary-graph") + .querySelector(".animation-summary-graph-path"); + } + } + + return null; +} diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_mutations_add_remove_immediately.js b/devtools/client/inspector/animation/test/browser_animation_logic_mutations_add_remove_immediately.js new file mode 100644 index 0000000000..c57f3c7b3b --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_logic_mutations_add_remove_immediately.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether the animation inspector will not crash when add animation then remove +// immediately. + +add_task(async function () { + const tab = await addTab( + URL_ROOT + "doc_mutations_add_remove_immediately.html" + ); + const { inspector, panel } = await openAnimationInspector(); + + info("Check state of the animation inspector after fast mutations"); + const onDispatch = waitForDispatch(inspector.store, "UPDATE_ANIMATIONS"); + await startMutation(tab); + await onDispatch; + ok( + panel.querySelector(".animation-error-message"), + "No animations message should display" + ); +}); + +async function startMutation(tab) { + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await content.wrappedJSObject.startMutation(); + }); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_mutations_fast.js b/devtools/client/inspector/animation/test/browser_animation_logic_mutations_fast.js new file mode 100644 index 0000000000..516a150e42 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_logic_mutations_fast.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether the animation inspector will not crash when remove/add animations faster. + +add_task(async function () { + const tab = await addTab(URL_ROOT + "doc_mutations_fast.html"); + const { inspector } = await openAnimationInspector(); + + info("Check state of the animation inspector after fast mutations"); + await startFastMutations(tab); + ok( + inspector.panelWin.document.getElementById("animation-container"), + "Animation inspector should be live" + ); +}); + +async function startFastMutations(tab) { + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await content.wrappedJSObject.startFastMutations(); + }); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_mutations_properties.js b/devtools/client/inspector/animation/test/browser_animation_logic_mutations_properties.js new file mode 100644 index 0000000000..9ec3d58be9 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_logic_mutations_properties.js @@ -0,0 +1,103 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether animation was changed after altering following properties. +// * delay +// * direction +// * duration +// * easing (animationTimingFunction in case of CSS Animationns) +// * fill +// * iterations +// * endDelay (script animation only) +// * iterationStart (script animation only) +// * playbackRate (script animation only) + +const SEC = 1000; +const TEST_EFFECT_TIMING = { + delay: 20 * SEC, + direction: "reverse", + duration: 20 * SEC, + easing: "steps(1)", + endDelay: 20 * SEC, + fill: "backwards", + iterations: 20, + iterationStart: 20 * SEC, +}; +const TEST_PLAYBACK_RATE = 0.1; + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".animated", ".end-delay"]); + const { animationInspector } = await openAnimationInspector(); + await setCSSAnimationProperties(animationInspector); + await assertProperties(animationInspector.state.animations[0], false); + await setScriptAnimationProperties(animationInspector); + await assertProperties(animationInspector.state.animations[1], true); +}); + +async function setCSSAnimationProperties(animationInspector) { + const properties = { + animationDelay: `${TEST_EFFECT_TIMING.delay}ms`, + animationDirection: TEST_EFFECT_TIMING.direction, + animationDuration: `${TEST_EFFECT_TIMING.duration}ms`, + animationFillMode: TEST_EFFECT_TIMING.fill, + animationIterationCount: TEST_EFFECT_TIMING.iterations, + animationTimingFunction: TEST_EFFECT_TIMING.easing, + }; + + await setStyles(animationInspector, ".animated", properties); +} + +async function setScriptAnimationProperties(animationInspector) { + await setEffectTimingAndPlayback( + animationInspector, + ".end-delay", + TEST_EFFECT_TIMING, + TEST_PLAYBACK_RATE + ); +} + +async function assertProperties(animation, isScriptAnimation) { + await waitUntil(() => animation.state.delay === TEST_EFFECT_TIMING.delay); + ok(true, `Delay should be ${TEST_EFFECT_TIMING.delay}`); + + await waitUntil( + () => animation.state.direction === TEST_EFFECT_TIMING.direction + ); + ok(true, `Direction should be ${TEST_EFFECT_TIMING.direction}`); + + await waitUntil( + () => animation.state.duration === TEST_EFFECT_TIMING.duration + ); + ok(true, `Duration should be ${TEST_EFFECT_TIMING.duration}`); + + await waitUntil(() => animation.state.fill === TEST_EFFECT_TIMING.fill); + ok(true, `Fill should be ${TEST_EFFECT_TIMING.fill}`); + + await waitUntil( + () => animation.state.iterationCount === TEST_EFFECT_TIMING.iterations + ); + ok(true, `Iterations should be ${TEST_EFFECT_TIMING.iterations}`); + + if (isScriptAnimation) { + await waitUntil(() => animation.state.easing === TEST_EFFECT_TIMING.easing); + ok(true, `Easing should be ${TEST_EFFECT_TIMING.easing}`); + + await waitUntil( + () => animation.state.iterationStart === TEST_EFFECT_TIMING.iterationStart + ); + ok(true, `IterationStart should be ${TEST_EFFECT_TIMING.iterationStart}`); + + await waitUntil(() => animation.state.playbackRate === TEST_PLAYBACK_RATE); + ok(true, `PlaybackRate should be ${TEST_PLAYBACK_RATE}`); + } else { + await waitUntil( + () => + animation.state.animationTimingFunction === TEST_EFFECT_TIMING.easing + ); + + ok(true, `AnimationTimingFunction should be ${TEST_EFFECT_TIMING.easing}`); + } +} diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_overflowed_delay_end-delay.js b/devtools/client/inspector/animation/test/browser_animation_logic_overflowed_delay_end-delay.js new file mode 100644 index 0000000000..3d1c71b6c3 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_logic_overflowed_delay_end-delay.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that animations with an overflowed delay and end delay are not displayed. + +add_task(async function () { + await addTab(URL_ROOT + "doc_overflowed_delay_end_delay.html"); + const { panel } = await openAnimationInspector(); + + info("Check the number of animation item"); + const animationItemEls = panel.querySelectorAll( + ".animation-list .animation-item" + ); + is( + animationItemEls.length, + 1, + "The number of animations displayed should be 1" + ); + + info("Check the id of animation displayed"); + const animationNameEl = animationItemEls[0].querySelector(".animation-name"); + is( + animationNameEl.textContent, + "big-iteration-start", + "The animation name should be 'big-iteration-start'" + ); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_logic_scroll-amount.js b/devtools/client/inspector/animation/test/browser_animation_logic_scroll-amount.js new file mode 100644 index 0000000000..11835cd880 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_logic_scroll-amount.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether the scroll amount of animation and animated property re-calculate after +// changing selected node. + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([ + ".animated", + ".multi", + ".longhand", + ".negative-delay", + ]); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + + info( + "Set the scroll amount of animation and animated property to the bottom" + ); + const onDetailRendered = animationInspector.once( + "animation-keyframes-rendered" + ); + await clickOnAnimationByTargetSelector( + animationInspector, + panel, + ".longhand" + ); + await onDetailRendered; + + await waitUntil(() => panel.querySelectorAll(".animation-item").length === 5); + const bottomAnimationEl = await findAnimationItemByIndex(panel, 4); + const bottomAnimatedPropertyEl = panel.querySelector( + ".animated-property-item:last-child" + ); + bottomAnimationEl.scrollIntoView(false); + bottomAnimatedPropertyEl.scrollIntoView(false); + + info("Hold the scroll amount"); + const animationInspectionPanel = bottomAnimationEl.closest( + ".progress-inspection-panel" + ); + const animatedPropertyInspectionPanel = bottomAnimatedPropertyEl.closest( + ".progress-inspection-panel" + ); + const initialScrollTopOfAnimation = animationInspectionPanel.scrollTop; + const initialScrollTopOfAnimatedProperty = + animatedPropertyInspectionPanel.scrollTop; + + info( + "Check whether the scroll amount re-calculate after changing the count of items" + ); + await selectNode(".negative-delay", inspector); + await waitUntil( + () => + initialScrollTopOfAnimation > animationInspectionPanel.scrollTop && + initialScrollTopOfAnimatedProperty > + animatedPropertyInspectionPanel.scrollTop + ); + ok( + true, + "Scroll amount for animation list should be less than previous state" + ); + ok( + true, + "Scroll amount for animated property list should be less than previous state" + ); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_pause-resume-button.js b/devtools/client/inspector/animation/test/browser_animation_pause-resume-button.js new file mode 100644 index 0000000000..8a8d9f848b --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_pause-resume-button.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following PauseResumeButton component: +// * element existence +// * state during running animations +// * state during pausing animations +// * make animations to pause by push button +// * make animations to resume by push button + +add_task(async function () { + await addTab(URL_ROOT + "doc_custom_playback_rate.html"); + const { animationInspector, panel } = await openAnimationInspector(); + + info("Checking pause/resume button existence"); + const buttonEl = panel.querySelector(".pause-resume-button"); + ok(buttonEl, "pause/resume button should exist"); + + info("Checking state during running animations"); + ok( + !buttonEl.classList.contains("paused"), + "State of button should be running" + ); + + info("Checking button makes animations to pause"); + clickOnPauseResumeButton(animationInspector, panel); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + ok(true, "All of animtion are paused"); + ok(buttonEl.classList.contains("paused"), "State of button should be paused"); + + info("Checking button makes animations to resume"); + clickOnPauseResumeButton(animationInspector, panel); + await waitUntilAnimationsPlayState(animationInspector, "running"); + ok(true, "All of animtion are running"); + ok( + !buttonEl.classList.contains("paused"), + "State of button should be resumed" + ); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_end-time.js b/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_end-time.js new file mode 100644 index 0000000000..1ef8606afd --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_end-time.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether the animation can rewind if the current time is over end time when +// the resume button clicked. + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([ + ".animated", + ".end-delay", + ".long", + ".negative-delay", + ]); + const { animationInspector, panel } = await openAnimationInspector(); + + info("Check animations state after resuming with infinite animation"); + info("Make the current time of animation to be over its end time"); + clickOnCurrentTimeScrubberController(animationInspector, panel, 1); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + info("Resume animations"); + clickOnPauseResumeButton(animationInspector, panel); + await wait(1000); + assertPlayState(animationInspector.state.animations, [ + "running", + "finished", + "finished", + "finished", + ]); + clickOnCurrentTimeScrubberController(animationInspector, panel, 0); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + + info("Check animations state after resuming without infinite animation"); + info("Remove infinite animation"); + await setClassAttribute(animationInspector, ".animated", "ball still"); + await waitUntil(() => panel.querySelectorAll(".animation-item").length === 3); + + info("Make the current time of animation to be over its end time"); + clickOnCurrentTimeScrubberController(animationInspector, panel, 1.1); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + await changePlaybackRateSelector(animationInspector, panel, 0.1); + info("Resume animations"); + clickOnPauseResumeButton(animationInspector, panel); + await waitUntilAnimationsPlayState(animationInspector, "running"); + assertCurrentTimeLessThanDuration(animationInspector.state.animations); + assertScrubberPosition(panel); +}); + +function assertPlayState(animations, expectedState) { + animations.forEach((animation, index) => { + is( + animation.state.playState, + expectedState[index], + `The playState of animation [${index}] should be ${expectedState[index]}` + ); + }); +} + +function assertCurrentTimeLessThanDuration(animations) { + animations.forEach((animation, index) => { + ok( + animation.state.currentTime < animation.state.duration, + `The current time of animation[${index}] should be less than its duration` + ); + }); +} + +function assertScrubberPosition(panel) { + const scrubberEl = panel.querySelector(".current-time-scrubber"); + const marginInlineStart = parseFloat(scrubberEl.style.marginInlineStart); + ok( + marginInlineStart >= 0, + "The translateX of scrubber position should be zero or more" + ); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_respectively.js b/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_respectively.js new file mode 100644 index 0000000000..ad84e4c257 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_respectively.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether pausing/resuming the each animations correctly. + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".animated", ".compositor-all"]); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + const buttonEl = panel.querySelector(".pause-resume-button"); + + info( + "Check '.compositor-all' animation is still running " + + "after even pausing '.animated' animation" + ); + await selectNode(".animated", inspector); + clickOnPauseResumeButton(animationInspector, panel); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + ok(buttonEl.classList.contains("paused"), "State of button should be paused"); + await selectNode("body", inspector); + await assertStatus( + animationInspector.state.animations, + buttonEl, + ["paused", "running"], + false + ); + + info( + "Check both animations are paused after clicking pause/resume " + + "while displaying both animations" + ); + clickOnPauseResumeButton(animationInspector, panel); + await assertStatus( + animationInspector.state.animations, + buttonEl, + ["paused", "paused"], + true + ); + + info( + "Check '.animated' animation is still paused " + + "after even resuming '.compositor-all' animation" + ); + await selectNode(".compositor-all", inspector); + clickOnPauseResumeButton(animationInspector, panel); + await waitUntil(() => + animationInspector.state.animations.some( + a => a.state.playState === "running" + ) + ); + ok( + !buttonEl.classList.contains("paused"), + "State of button should be running" + ); + await selectNode("body", inspector); + await assertStatus( + animationInspector.state.animations, + buttonEl, + ["paused", "running"], + false + ); +}); + +async function assertStatus( + animations, + buttonEl, + expectedAnimationStates, + shouldButtonPaused +) { + await waitUntil(() => { + for (let i = 0; i < expectedAnimationStates.length; i++) { + const animation = animations[i]; + const state = expectedAnimationStates[i]; + if (animation.state.playState !== state) { + return false; + } + } + return true; + }); + expectedAnimationStates.forEach((state, index) => { + is( + animations[index].state.playState, + state, + `Animation ${index} should be ${state}` + ); + }); + + is( + buttonEl.classList.contains("paused"), + shouldButtonPaused, + "State of button is correct" + ); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_spacebar.js b/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_spacebar.js new file mode 100644 index 0000000000..7a27b1bd07 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_pause-resume-button_spacebar.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following PauseResumeButton component with spacebar: +// * make animations to pause/resume by spacebar +// * combination with other UI components + +add_task(async function () { + await addTab(URL_ROOT + "doc_custom_playback_rate.html"); + const { animationInspector, panel } = await openAnimationInspector(); + + info("Checking spacebar makes animations to pause"); + await testPauseAndResumeBySpacebar(animationInspector, panel); + + info( + "Checking spacebar makes animations to pause when the button has the focus" + ); + const pauseResumeButton = panel.querySelector(".pause-resume-button"); + await testPauseAndResumeBySpacebar(animationInspector, pauseResumeButton); + + info("Checking spacebar works with other UI components"); + // To pause + clickOnPauseResumeButton(animationInspector, panel); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + // To resume + sendSpaceKeyEvent(animationInspector, panel); + await waitUntilAnimationsPlayState(animationInspector, "running"); + // To pause + clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + // To resume + clickOnPauseResumeButton(animationInspector, panel); + await waitUntilAnimationsPlayState(animationInspector, "running"); + // To pause + sendSpaceKeyEvent(animationInspector, panel); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + ok(true, "All components that can make animations pause/resume works fine"); +}); + +async function testPauseAndResumeBySpacebar(animationInspector, element) { + await sendSpaceKeyEvent(animationInspector, element); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + ok(true, "Space key can pause animations"); + await sendSpaceKeyEvent(animationInspector, element); + await waitUntilAnimationsPlayState(animationInspector, "running"); + ok(true, "Space key can resume animations"); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_playback-rate-selector.js b/devtools/client/inspector/animation/test/browser_animation_playback-rate-selector.js new file mode 100644 index 0000000000..8552eae138 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_playback-rate-selector.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following PlaybackRateSelector component: +// * element existence +// * make playback rate of animations by the selector +// * in case of animations have mixed playback rate +// * in case of animations have playback rate which is not default selectable value + +add_task(async function () { + await addTab(URL_ROOT + "doc_custom_playback_rate.html"); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + + info("Checking playback rate selector existence"); + const selectEl = panel.querySelector(".playback-rate-selector"); + ok(selectEl, "scrubber controller should exist"); + + info( + "Checking playback rate existence which includes custom rate of animations" + ); + const expectedPlaybackRates = [0.1, 0.25, 0.5, 1, 1.5, 2, 5, 10]; + await assertPlaybackRateOptions(selectEl, expectedPlaybackRates); + + info("Checking selected playback rate"); + is(Number(selectEl.value), 1.5, "Selected option should be 1.5"); + + info("Checking playback rate of animations"); + await changePlaybackRateSelector(animationInspector, panel, 0.5); + await assertPlaybackRate(animationInspector, 0.5); + + info("Checking mixed playback rate"); + await selectNode("div", inspector); + await waitUntil(() => panel.querySelectorAll(".animation-item").length === 1); + await changePlaybackRateSelector(animationInspector, panel, 2); + await assertPlaybackRate(animationInspector, 2); + await selectNode("body", inspector); + await waitUntil(() => panel.querySelectorAll(".animation-item").length === 2); + await waitUntil(() => selectEl.value === ""); + ok(true, "Selected option should be empty"); + + info("Checking playback rate after re-setting"); + await changePlaybackRateSelector(animationInspector, panel, 1); + await assertPlaybackRate(animationInspector, 1); + + info( + "Checking whether custom playback rate exist " + + "after selecting another playback rate" + ); + await assertPlaybackRateOptions(selectEl, expectedPlaybackRates); +}); + +async function assertPlaybackRate(animationInspector, rate) { + await waitUntil(() => + animationInspector.state?.animations.every( + ({ state }) => state.playbackRate === rate + ) + ); + ok(true, `Playback rate of animations should be ${rate}`); +} + +async function assertPlaybackRateOptions(selectEl, expectedPlaybackRates) { + await waitUntil(() => { + if (selectEl.options.length !== expectedPlaybackRates.length) { + return false; + } + + for (let i = 0; i < selectEl.options.length; i++) { + const optionEl = selectEl.options[i]; + const expectedPlaybackRate = expectedPlaybackRates[i]; + if (Number(optionEl.value) !== expectedPlaybackRate) { + return false; + } + } + + return true; + }); + ok(true, "Content of playback rate options are correct"); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_pseudo-element.js b/devtools/client/inspector/animation/test/browser_animation_pseudo-element.js new file mode 100644 index 0000000000..00e267f9f8 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_pseudo-element.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for pseudo element. + +const TEST_DATA = [ + { + expectedTargetLabel: "::before", + expectedAnimationNameLabel: "body", + expectedKeyframsGraphPathSegments: [ + { x: 0, y: 0 }, + { x: 1000, y: 100 }, + ], + }, + { + expectedTargetLabel: "::before", + expectedAnimationNameLabel: "div-before", + expectedKeyframsGraphPathSegments: [ + { x: 0, y: 100 }, + { x: 1000, y: 0 }, + ], + }, + { + expectedTargetLabel: "::after", + expectedAnimationNameLabel: "div-after", + }, + { + expectedTargetLabel: "::marker", + expectedAnimationNameLabel: "div-marker", + }, +]; + +add_task(async function () { + await addTab(URL_ROOT + "doc_pseudo.html"); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + + info("Checking count of animation item for pseudo elements"); + is( + panel.querySelectorAll(".animation-list .animation-item").length, + TEST_DATA.length, + `Count of animation item should be ${TEST_DATA.length}` + ); + + info("Checking content of each animation item"); + for (let i = 0; i < TEST_DATA.length; i++) { + const testData = TEST_DATA[i]; + info(`Checking pseudo element for ${testData.expectedTargetLabel}`); + const animationItemEl = await findAnimationItemByIndex(panel, i); + + info("Checking text content of animation target"); + const animationTargetEl = animationItemEl.querySelector( + ".animation-list .animation-item .animation-target" + ); + is( + animationTargetEl.textContent, + testData.expectedTargetLabel, + `Text content of animation target[${i}] should be ${testData.expectedTarget}` + ); + + info("Checking text content of animation name"); + const animationNameEl = animationItemEl.querySelector(".animation-name"); + is( + animationNameEl.textContent, + testData.expectedAnimationNameLabel, + `The animation name should be ${testData.expectedAnimationNameLabel}` + ); + } + + info( + "Checking whether node is selected correctly " + + "when click on the first inspector icon on Reps component" + ); + let onDetailRendered = animationInspector.once( + "animation-keyframes-rendered" + ); + await clickOnTargetNode(animationInspector, panel, 0); + await onDetailRendered; + assertAnimationCount(panel, 1); + assertAnimationNameLabel(panel, TEST_DATA[0].expectedAnimationNameLabel); + assertKeyframesGraphPathSegments( + panel, + TEST_DATA[0].expectedKeyframsGraphPathSegments + ); + + info("Select <body> again to reset the animation list"); + await selectNode("body", inspector); + + info( + "Checking whether node is selected correctly " + + "when click on the second inspector icon on Reps component" + ); + onDetailRendered = animationInspector.once("animation-keyframes-rendered"); + await clickOnTargetNode(animationInspector, panel, 1); + await onDetailRendered; + assertAnimationCount(panel, 1); + assertAnimationNameLabel(panel, TEST_DATA[1].expectedAnimationNameLabel); + assertKeyframesGraphPathSegments( + panel, + TEST_DATA[1].expectedKeyframsGraphPathSegments + ); +}); + +function assertAnimationCount(panel, expectedCount) { + info("Checking count of animation item"); + is( + panel.querySelectorAll(".animation-list .animation-item").length, + expectedCount, + `Count of animation item should be ${expectedCount}` + ); +} + +function assertAnimationNameLabel(panel, expectedAnimationNameLabel) { + info("Checking the animation name label"); + is( + panel.querySelector(".animation-list .animation-item .animation-name") + .textContent, + expectedAnimationNameLabel, + `The animation name should be ${expectedAnimationNameLabel}` + ); +} + +function assertKeyframesGraphPathSegments(panel, expectedPathSegments) { + info("Checking the keyframes graph path segments"); + const pathEl = panel.querySelector(".keyframes-graph-path path"); + assertPathSegments(pathEl, true, expectedPathSegments); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_rewind-button.js b/devtools/client/inspector/animation/test/browser_animation_rewind-button.js new file mode 100644 index 0000000000..f74518095b --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_rewind-button.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +// Test for following RewindButton component: +// * element existence +// * make animations to rewind to zero +// * the state should be always paused after rewinding + +add_task(async function () { + await addTab(URL_ROOT + "doc_multi_timings.html"); + await removeAnimatedElementsExcept([".delay-negative", ".delay-positive"]); + const { animationInspector, panel } = await openAnimationInspector(); + + info("Checking button existence"); + ok(panel.querySelector(".rewind-button"), "Rewind button should exist"); + + info("Checking rewind button makes animations to rewind to zero"); + clickOnRewindButton(animationInspector, panel); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + await waitUntilCurrentTimeChangedAt(animationInspector, 0); + ok(true, "Rewind button make current time 0"); + + info("Checking rewind button makes animations after clicking scrubber"); + clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5); + clickOnRewindButton(animationInspector, panel); + await waitUntilCurrentTimeChangedAt(animationInspector, 0); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + ok(true, "Rewind button make current time 0 even after clicking scrubber"); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_short-duration.js b/devtools/client/inspector/animation/test/browser_animation_short-duration.js new file mode 100644 index 0000000000..c953d886ff --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_short-duration.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test tooltips and iteration path of summary graph with short duration animation. + +add_task(async function () { + await addTab(URL_ROOT + "doc_short_duration.html"); + const { panel } = await openAnimationInspector(); + + const animationItemEl = await findAnimationItemByTargetSelector( + panel, + ".short" + ); + const summaryGraphEl = animationItemEl.querySelector( + ".animation-summary-graph" + ); + + info("Check tooltip"); + assertTooltip(summaryGraphEl); + + info("Check iteration path"); + assertIterationPath(summaryGraphEl); +}); + +function assertTooltip(summaryGraphEl) { + const tooltip = summaryGraphEl.getAttribute("title"); + const expected = "Duration: 0s"; + ok(tooltip.includes(expected), `Tooltip should include '${expected}'`); +} + +function assertIterationPath(summaryGraphEl) { + const pathEl = summaryGraphEl.querySelector( + ".animation-computed-timing-path .animation-iteration-path" + ); + const expected = [ + { x: 0, y: 0 }, + { x: 0.999, y: 99.9 }, + { x: 1, y: 0 }, + ]; + assertPathSegments(pathEl, true, expected); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_animation-name.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_animation-name.js new file mode 100644 index 0000000000..0e9c52449d --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_animation-name.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following AnimationName component works. +// * element existance +// * name text + +const TEST_DATA = [ + { + targetClass: "cssanimation-normal", + expectedLabel: "cssanimation", + }, + { + targetClass: "cssanimation-linear", + expectedLabel: "cssanimation", + }, + { + targetClass: "delay-positive", + expectedLabel: "test-delay-animation", + }, + { + targetClass: "delay-negative", + expectedLabel: "test-negative-delay-animation", + }, + { + targetClass: "easing-step", + }, +]; + +add_task(async function () { + await addTab(URL_ROOT + "doc_multi_timings.html"); + await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`)); + const { panel } = await openAnimationInspector(); + + for (const { targetClass, expectedLabel } of TEST_DATA) { + const animationItemEl = await findAnimationItemByTargetSelector( + panel, + `.${targetClass}` + ); + + info(`Checking animation name element existance for ${targetClass}`); + const animationNameEl = animationItemEl.querySelector(".animation-name"); + + if (expectedLabel) { + ok( + animationNameEl, + "The animation name element should be in animation item element" + ); + is( + animationNameEl.textContent, + expectedLabel, + `The animation name should be ${expectedLabel}` + ); + } else { + ok( + !animationNameEl, + "The animation name element should not be in animation item element" + ); + } + } +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_compositor.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_compositor.js new file mode 100644 index 0000000000..186c54cba6 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_compositor.js @@ -0,0 +1,121 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that when animations displayed in the timeline are running on the +// compositor, they get a special icon and information in the tooltip. + +requestLongerTimeout(2); + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([ + ".compositor-all", + ".compositor-notall", + ".no-compositor", + ]); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + + info("Check animation whose all properties are running on compositor"); + const summaryGraphAllEl = await findSummaryGraph(".compositor-all", panel); + ok( + summaryGraphAllEl.classList.contains("compositor"), + "The element has the compositor css class" + ); + ok( + hasTooltip( + summaryGraphAllEl, + ANIMATION_L10N.getStr("player.allPropertiesOnCompositorTooltip") + ), + "The element has the right tooltip content" + ); + + info("Check animation is not running on compositor"); + const summaryGraphNoEl = await findSummaryGraph(".no-compositor", panel); + ok( + !summaryGraphNoEl.classList.contains("compositor"), + "The element does not have the compositor css class" + ); + ok( + !hasTooltip( + summaryGraphNoEl, + ANIMATION_L10N.getStr("player.allPropertiesOnCompositorTooltip") + ), + "The element does not have oncompositor tooltip content" + ); + ok( + !hasTooltip( + summaryGraphNoEl, + ANIMATION_L10N.getStr("player.somePropertiesOnCompositorTooltip") + ), + "The element does not have oncompositor tooltip content" + ); + + info( + "Select a node has animation whose some properties are running on compositor" + ); + await selectNode(".compositor-notall", inspector); + const summaryGraphEl = await findSummaryGraph(".compositor-notall", panel); + ok( + summaryGraphEl.classList.contains("compositor"), + "The element has the compositor css class" + ); + ok( + hasTooltip( + summaryGraphEl, + ANIMATION_L10N.getStr("player.somePropertiesOnCompositorTooltip") + ), + "The element has the right tooltip content" + ); + + info("Check compositor sign after pausing"); + clickOnPauseResumeButton(animationInspector, panel); + await waitUntil(() => !summaryGraphEl.classList.contains("compositor")); + ok( + true, + "The element should not have the compositor css class after pausing" + ); + + info("Check compositor sign after resuming"); + clickOnPauseResumeButton(animationInspector, panel); + await waitUntil(() => summaryGraphEl.classList.contains("compositor")); + ok(true, "The element should have the compositor css class after resuming"); + + info("Check compositor sign after rewind"); + clickOnRewindButton(animationInspector, panel); + await waitUntil(() => !summaryGraphEl.classList.contains("compositor")); + ok( + true, + "The element should not have the compositor css class after rewinding" + ); + clickOnPauseResumeButton(animationInspector, panel); + await waitUntil(() => summaryGraphEl.classList.contains("compositor")); + ok(true, "The element should have the compositor css class after resuming"); + + info("Check compositor sign after setting the current time"); + clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5); + await waitUntil(() => !summaryGraphEl.classList.contains("compositor")); + ok( + true, + "The element should not have the compositor css class " + + "after setting the current time" + ); + clickOnPauseResumeButton(animationInspector, panel); + await waitUntil(() => summaryGraphEl.classList.contains("compositor")); + ok(true, "The element should have the compositor css class after resuming"); +}); + +async function findSummaryGraph(selector, panel) { + const animationItemEl = await findAnimationItemByTargetSelector( + panel, + selector + ); + return animationItemEl.querySelector(".animation-summary-graph"); +} + +function hasTooltip(summaryGraphEl, expected) { + const tooltip = summaryGraphEl.getAttribute("title"); + return tooltip.includes(expected); +} diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_1.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_1.js new file mode 100644 index 0000000000..a81b971559 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_1.js @@ -0,0 +1,208 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following ComputedTimingPath component works. +// * element existance +// * iterations: path, count +// * delay: path +// * fill: path +// * endDelay: path + +/* import-globals-from summary-graph_computed-timing-path_head.js */ +Services.scriptloader.loadSubScript( + CHROME_URL_ROOT + "summary-graph_computed-timing-path_head.js", + this +); + +const TEST_DATA = [ + { + targetClass: "cssanimation-normal", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 250000, y: 40.851 }, + { x: 500000, y: 80.24 }, + { x: 750000, y: 96.05 }, + { x: 1000000, y: 100 }, + { x: 1000000, y: 0 }, + ], + ], + }, + { + targetClass: "cssanimation-linear", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 250000, y: 25 }, + { x: 500000, y: 50 }, + { x: 750000, y: 75 }, + { x: 1000000, y: 100 }, + { x: 1000000, y: 0 }, + ], + ], + }, + { + targetClass: "delay-positive", + expectedDelayPath: [ + { x: 0, y: 0 }, + { x: 500000, y: 0 }, + ], + expectedIterationPathList: [ + [ + { x: 500000, y: 0 }, + { x: 750000, y: 25 }, + { x: 1000000, y: 50 }, + { x: 1250000, y: 75 }, + { x: 1500000, y: 100 }, + { x: 1500000, y: 0 }, + ], + ], + }, + { + targetClass: "easing-step", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 499999, y: 0 }, + { x: 500000, y: 50 }, + { x: 999999, y: 50 }, + { x: 1000000, y: 0 }, + ], + ], + }, + { + targetClass: "enddelay-positive", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 250000, y: 25 }, + { x: 500000, y: 50 }, + { x: 750000, y: 75 }, + { x: 1000000, y: 100 }, + { x: 1000000, y: 0 }, + ], + ], + expectedEndDelayPath: [ + { x: 1000000, y: 0 }, + { x: 1500000, y: 0 }, + ], + }, + { + targetClass: "enddelay-negative", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 250000, y: 25 }, + { x: 500000, y: 50 }, + { x: 500000, y: 0 }, + ], + ], + }, + { + targetClass: "enddelay-with-fill-forwards", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 250000, y: 25 }, + { x: 500000, y: 50 }, + { x: 750000, y: 75 }, + { x: 1000000, y: 100 }, + { x: 1000000, y: 0 }, + ], + ], + expectedEndDelayPath: [ + { x: 1000000, y: 0 }, + { x: 1000000, y: 100 }, + { x: 1500000, y: 100 }, + { x: 1500000, y: 0 }, + ], + expectedForwardsPath: [ + { x: 1500000, y: 0 }, + { x: 1500000, y: 100 }, + ], + }, + { + targetClass: "enddelay-with-iterations-infinity", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 250000, y: 25 }, + { x: 500000, y: 50 }, + { x: 750000, y: 75 }, + { x: 1000000, y: 100 }, + { x: 1000000, y: 0 }, + ], + [ + { x: 1000000, y: 0 }, + { x: 1250000, y: 25 }, + { x: 1500000, y: 50 }, + ], + ], + isInfinity: true, + }, + { + targetClass: "direction-alternate-with-iterations-infinity", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 250000, y: 25 }, + { x: 500000, y: 50 }, + { x: 750000, y: 75 }, + { x: 1000000, y: 100 }, + { x: 1000000, y: 0 }, + ], + [ + { x: 1000000, y: 0 }, + { x: 1000000, y: 100 }, + { x: 1250000, y: 75 }, + { x: 1500000, y: 50 }, + ], + ], + isInfinity: true, + }, + { + targetClass: "direction-alternate-reverse-with-iterations-infinity", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 0, y: 100 }, + { x: 250000, y: 75 }, + { x: 500000, y: 50 }, + { x: 750000, y: 25 }, + { x: 1000000, y: 0 }, + ], + [ + { x: 1000000, y: 0 }, + { x: 1250000, y: 25 }, + { x: 1500000, y: 50 }, + ], + ], + isInfinity: true, + }, + { + targetClass: "direction-reverse-with-iterations-infinity", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 0, y: 100 }, + { x: 250000, y: 75 }, + { x: 500000, y: 50 }, + { x: 750000, y: 25 }, + { x: 1000000, y: 0 }, + ], + [ + { x: 1000000, y: 0 }, + { x: 1000000, y: 100 }, + { x: 1250000, y: 75 }, + { x: 1500000, y: 50 }, + ], + ], + isInfinity: true, + }, +]; + +add_task(async function () { + await testComputedTimingPath(TEST_DATA); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_2.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_2.js new file mode 100644 index 0000000000..e1e4c52ba6 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_2.js @@ -0,0 +1,192 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following ComputedTimingPath component works. +// * element existance +// * iterations: path, count +// * delay: path +// * fill: path +// * endDelay: path + +/* import-globals-from summary-graph_computed-timing-path_head.js */ +Services.scriptloader.loadSubScript( + CHROME_URL_ROOT + "summary-graph_computed-timing-path_head.js", + this +); + +const TEST_DATA = [ + { + targetClass: "fill-backwards", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 250000, y: 25 }, + { x: 500000, y: 50 }, + { x: 750000, y: 75 }, + { x: 1000000, y: 100 }, + { x: 1000000, y: 0 }, + ], + ], + }, + { + targetClass: "fill-backwards-with-delay-iterationstart", + expectedDelayPath: [ + { x: 0, y: 0 }, + { x: 0, y: 50 }, + { x: 500000, y: 50 }, + { x: 500000, y: 0 }, + ], + expectedIterationPathList: [ + [ + { x: 500000, y: 50 }, + { x: 750000, y: 75 }, + { x: 1000000, y: 100 }, + { x: 1000000, y: 0 }, + ], + [ + { x: 1000000, y: 0 }, + { x: 1250000, y: 25 }, + { x: 1500000, y: 50 }, + { x: 1500000, y: 0 }, + ], + ], + }, + { + targetClass: "fill-both", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 250000, y: 25 }, + { x: 500000, y: 50 }, + { x: 750000, y: 75 }, + { x: 1000000, y: 100 }, + { x: 1000000, y: 0 }, + ], + ], + expectedForwardsPath: [ + { x: 1000000, y: 0 }, + { x: 1000000, y: 100 }, + { x: 1500000, y: 100 }, + { x: 1500000, y: 0 }, + ], + }, + { + targetClass: "fill-both-width-delay-iterationstart", + expectedDelayPath: [ + { x: 0, y: 0 }, + { x: 0, y: 50 }, + { x: 500000, y: 50 }, + { x: 500000, y: 0 }, + ], + expectedIterationPathList: [ + [ + { x: 500000, y: 50 }, + { x: 750000, y: 75 }, + { x: 1000000, y: 100 }, + { x: 1000000, y: 0 }, + ], + [ + { x: 1000000, y: 0 }, + { x: 1250000, y: 25 }, + { x: 1500000, y: 50 }, + { x: 1500000, y: 0 }, + ], + ], + expectedForwardsPath: [ + { x: 1500000, y: 0 }, + { x: 1500000, y: 50 }, + ], + }, + { + targetClass: "fill-forwards", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 250000, y: 25 }, + { x: 500000, y: 50 }, + { x: 750000, y: 75 }, + { x: 1000000, y: 100 }, + { x: 1000000, y: 0 }, + ], + ], + expectedForwardsPath: [ + { x: 1000000, y: 0 }, + { x: 1000000, y: 100 }, + { x: 1500000, y: 100 }, + { x: 1500000, y: 0 }, + ], + }, + { + targetClass: "iterationstart", + expectedIterationPathList: [ + [ + { x: 0, y: 50 }, + { x: 250000, y: 75 }, + { x: 500000, y: 100 }, + { x: 500000, y: 0 }, + ], + [ + { x: 500000, y: 0 }, + { x: 750000, y: 25 }, + { x: 1000000, y: 50 }, + { x: 1000000, y: 0 }, + ], + ], + }, + { + targetClass: "no-compositor", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 250000, y: 25 }, + { x: 500000, y: 50 }, + { x: 750000, y: 75 }, + { x: 1000000, y: 100 }, + { x: 1000000, y: 0 }, + ], + ], + }, + { + targetClass: "keyframes-easing-step", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 499999, y: 0 }, + { x: 500000, y: 50 }, + { x: 999999, y: 50 }, + { x: 1000000, y: 0 }, + ], + ], + }, + { + targetClass: "narrow-keyframes", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 100000, y: 10 }, + { x: 110000, y: 10 }, + { x: 115000, y: 10 }, + { x: 129999, y: 10 }, + { x: 130000, y: 13 }, + { x: 135000, y: 13.5 }, + ], + ], + }, + { + targetClass: "duplicate-offsets", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 250000, y: 25 }, + { x: 500000, y: 50 }, + { x: 999999, y: 50 }, + ], + ], + }, +]; + +add_task(async function () { + await testComputedTimingPath(TEST_DATA); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_different-timescale.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_different-timescale.js new file mode 100644 index 0000000000..0b9bc79def --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_computed-timing-path_different-timescale.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the Computed Timing Path component for different time scales. + +add_task(async function () { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".animated", ".end-delay"]); + const { animationInspector, inspector, panel } = + await openAnimationInspector(); + + info("Checking the path for different time scale"); + let onDetailRendered = animationInspector.once( + "animation-keyframes-rendered" + ); + await selectNode(".animated", inspector); + await onDetailRendered; + const itemA = await findAnimationItemByTargetSelector(panel, ".animated"); + const pathStringA = itemA + .querySelector(".animation-iteration-path") + .getAttribute("d"); + + info("Select animation which has different time scale from no-compositor"); + onDetailRendered = animationInspector.once("animation-keyframes-rendered"); + await selectNode(".end-delay", inspector); + await onDetailRendered; + + info("Select no-compositor again"); + onDetailRendered = animationInspector.once("animation-keyframes-rendered"); + await selectNode(".animated", inspector); + await onDetailRendered; + const itemB = await findAnimationItemByTargetSelector(panel, ".animated"); + const pathStringB = itemB + .querySelector(".animation-iteration-path") + .getAttribute("d"); + is( + pathStringA, + pathStringB, + "Path string should be same even change the time scale" + ); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_delay-sign-rtl.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_delay-sign-rtl.js new file mode 100644 index 0000000000..591fc5f3fe --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_delay-sign-rtl.js @@ -0,0 +1,14 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + Services.scriptloader.loadSubScript( + CHROME_URL_ROOT + "summary-graph_delay-sign_head.js", + this + ); + await pushPref("intl.l10n.pseudo", "bidi"); + // eslint-disable-next-line no-undef + await testSummaryGraphDelaySign(); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_delay-sign.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_delay-sign.js new file mode 100644 index 0000000000..891d9fd90e --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_delay-sign.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + Services.scriptloader.loadSubScript( + CHROME_URL_ROOT + "summary-graph_delay-sign_head.js", + this + ); + // eslint-disable-next-line no-undef + await testSummaryGraphDelaySign(); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_effect-timing-path.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_effect-timing-path.js new file mode 100644 index 0000000000..6974eab6c6 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_effect-timing-path.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following EffectTimingPath component works. +// * element existance +// * path + +const TEST_DATA = [ + { + targetClass: "cssanimation-linear", + }, + { + targetClass: "delay-negative", + }, + { + targetClass: "easing-step", + expectedPath: [ + { x: 0, y: 0 }, + { x: 499999, y: 0 }, + { x: 500000, y: 50 }, + { x: 999999, y: 50 }, + { x: 1000000, y: 0 }, + ], + }, + { + targetClass: "keyframes-easing-step", + }, +]; + +add_task(async function () { + await addTab(URL_ROOT + "doc_multi_timings.html"); + await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`)); + const { panel } = await openAnimationInspector(); + + for (const { targetClass, expectedPath } of TEST_DATA) { + const animationItemEl = await findAnimationItemByTargetSelector( + panel, + `.${targetClass}` + ); + + info(`Checking effect timing path existance for ${targetClass}`); + const effectTimingPathEl = animationItemEl.querySelector( + ".animation-effect-timing-path" + ); + + if (expectedPath) { + ok( + effectTimingPathEl, + "The effect timing path element should be in animation item element" + ); + const pathEl = effectTimingPathEl.querySelector( + ".animation-iteration-path" + ); + assertPathSegments(pathEl, false, expectedPath); + } else { + ok( + !effectTimingPathEl, + "The effect timing path element should not be in animation item element" + ); + } + } +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_end-delay-sign-rtl.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_end-delay-sign-rtl.js new file mode 100644 index 0000000000..084e4acf1d --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_end-delay-sign-rtl.js @@ -0,0 +1,15 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from summary-graph_end-delay-sign_head.js */ + +add_task(async function () { + Services.scriptloader.loadSubScript( + CHROME_URL_ROOT + "summary-graph_end-delay-sign_head.js", + this + ); + await pushPref("intl.l10n.pseudo", "bidi"); + await testSummaryGraphEndDelaySign(); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_end-delay-sign.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_end-delay-sign.js new file mode 100644 index 0000000000..4382ed4c2d --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_end-delay-sign.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + Services.scriptloader.loadSubScript( + CHROME_URL_ROOT + "summary-graph_end-delay-sign_head.js", + this + ); + // eslint-disable-next-line no-undef + await testSummaryGraphEndDelaySign(); +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_layout-by-seek.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_layout-by-seek.js new file mode 100644 index 0000000000..5f1c808728 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_layout-by-seek.js @@ -0,0 +1,150 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether the layout of graphs were broken by seek and resume. + +add_task(async function () { + await addTab(URL_ROOT + "doc_multi_timings.html"); + await removeAnimatedElementsExcept([ + ".delay-positive", + ".delay-negative", + ".enddelay-positive", + ".enddelay-negative", + ]); + const { animationInspector, panel } = await openAnimationInspector(); + + info("Get initial coordinates result as test data"); + const initialCoordinatesResult = []; + + for (let i = 0; i < animationInspector.state.animations.length; i++) { + const itemEl = await findAnimationItemByIndex(panel, i); + const svgEl = itemEl.querySelector("svg"); + const svgViewBoxX = svgEl.viewBox.baseVal.x; + const svgViewBoxWidth = svgEl.viewBox.baseVal.width; + + const pathEl = svgEl.querySelector(".animation-computed-timing-path"); + const pathX = pathEl.transform.baseVal[0].matrix.e; + + const delayEl = itemEl.querySelector(".animation-delay-sign"); + let delayX = null; + let delayWidth = null; + + if (delayEl) { + const computedStyle = delayEl.ownerGlobal.getComputedStyle(delayEl); + delayX = computedStyle.left; + delayWidth = computedStyle.width; + } + + const endDelayEl = itemEl.querySelector(".animation-end-delay-sign"); + let endDelayX = null; + let endDelayWidth = null; + + if (endDelayEl) { + const computedStyle = endDelayEl.ownerGlobal.getComputedStyle(endDelayEl); + endDelayX = computedStyle.left; + endDelayWidth = computedStyle.width; + } + + const coordinates = { + svgViewBoxX, + svgViewBoxWidth, + pathX, + delayX, + delayWidth, + endDelayX, + endDelayWidth, + }; + initialCoordinatesResult.push(coordinates); + } + + info("Set currentTime to rear of the end of animation of .delay-negative."); + clickOnCurrentTimeScrubberController(animationInspector, panel, 0.75); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + info("Resume animations"); + clickOnPauseResumeButton(animationInspector, panel); + // As some animations may be finished, we check if some animations will be running. + await waitUntil(() => + animationInspector.state.animations.some( + a => a.state.playState === "running" + ) + ); + + info("Check the layout"); + const itemEls = panel.querySelectorAll(".animation-item"); + is( + itemEls.length, + initialCoordinatesResult.length, + "Count of animation item should be same to initial items" + ); + + info("Check the coordinates"); + checkExpectedCoordinates(itemEls, initialCoordinatesResult); +}); + +function checkExpectedCoordinates(itemEls, initialCoordinatesResult) { + for (let i = 0; i < itemEls.length; i++) { + const expectedCoordinates = initialCoordinatesResult[i]; + const itemEl = itemEls[i]; + const svgEl = itemEl.querySelector("svg"); + is( + svgEl.viewBox.baseVal.x, + expectedCoordinates.svgViewBoxX, + "X of viewBox of svg should be same" + ); + is( + svgEl.viewBox.baseVal.width, + expectedCoordinates.svgViewBoxWidth, + "Width of viewBox of svg should be same" + ); + + const pathEl = svgEl.querySelector(".animation-computed-timing-path"); + is( + pathEl.transform.baseVal[0].matrix.e, + expectedCoordinates.pathX, + "X of tansform of path element should be same" + ); + + const delayEl = itemEl.querySelector(".animation-delay-sign"); + + if (delayEl) { + const computedStyle = delayEl.ownerGlobal.getComputedStyle(delayEl); + is( + computedStyle.left, + expectedCoordinates.delayX, + "X of delay sign should be same" + ); + is( + computedStyle.width, + expectedCoordinates.delayWidth, + "Width of delay sign should be same" + ); + } else { + ok(!expectedCoordinates.delayX, "X of delay sign should exist"); + ok(!expectedCoordinates.delayWidth, "Width of delay sign should exist"); + } + + const endDelayEl = itemEl.querySelector(".animation-end-delay-sign"); + + if (endDelayEl) { + const computedStyle = endDelayEl.ownerGlobal.getComputedStyle(endDelayEl); + is( + computedStyle.left, + expectedCoordinates.endDelayX, + "X of endDelay sign should be same" + ); + is( + computedStyle.width, + expectedCoordinates.endDelayWidth, + "Width of endDelay sign should be same" + ); + } else { + ok(!expectedCoordinates.endDelayX, "X of endDelay sign should exist"); + ok( + !expectedCoordinates.endDelayWidth, + "Width of endDelay sign should exist" + ); + } + } +} diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_negative-delay-path.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_negative-delay-path.js new file mode 100644 index 0000000000..8ed638c443 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_negative-delay-path.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following NegativeDelayPath component works. +// * element existance +// * path + +const TEST_DATA = [ + { + targetClass: "delay-positive", + }, + { + targetClass: "delay-negative", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 0, y: 50 }, + { x: 250000, y: 75 }, + { x: 500000, y: 100 }, + { x: 500000, y: 0 }, + ], + ], + expectedNegativePath: [ + { x: -500000, y: 0 }, + { x: -250000, y: 25 }, + { x: 0, y: 50 }, + { x: 0, y: 0 }, + ], + }, + { + targetClass: "delay-negative-25", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 0, y: 25 }, + { x: 750000, y: 100 }, + { x: 750000, y: 0 }, + ], + ], + expectedNegativePath: [ + { x: -250000, y: 0 }, + { x: 0, y: 25 }, + { x: 0, y: 0 }, + ], + }, + { + targetClass: "delay-negative-75", + expectedIterationPathList: [ + [ + { x: 0, y: 0 }, + { x: 0, y: 75 }, + { x: 250000, y: 100 }, + { x: 250000, y: 0 }, + ], + ], + expectedNegativePath: [ + { x: -750000, y: 0 }, + { x: 0, y: 75 }, + { x: 0, y: 0 }, + ], + }, +]; + +add_task(async function () { + await addTab(URL_ROOT + "doc_multi_timings.html"); + await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`)); + const { panel } = await openAnimationInspector(); + + for (const { + targetClass, + expectedIterationPathList, + expectedNegativePath, + } of TEST_DATA) { + const animationItemEl = await findAnimationItemByTargetSelector( + panel, + `.${targetClass}` + ); + + info(`Checking negative delay path existence for ${targetClass}`); + const negativeDelayPathEl = animationItemEl.querySelector( + ".animation-negative-delay-path" + ); + + if (expectedNegativePath) { + ok( + negativeDelayPathEl, + "The negative delay path element should be in animation item element" + ); + const pathEl = negativeDelayPathEl.querySelector("path"); + assertPathSegments(pathEl, true, expectedNegativePath); + } else { + ok( + !negativeDelayPathEl, + "The negative delay path element should not be in animation item element" + ); + } + + if (!expectedIterationPathList) { + // We don't need to test for iteration path. + continue; + } + + info(`Checking computed timing path existance for ${targetClass}`); + const computedTimingPathEl = animationItemEl.querySelector( + ".animation-computed-timing-path" + ); + ok( + computedTimingPathEl, + "The computed timing path element should be in each animation item element" + ); + + info(`Checking iteration path list for ${targetClass}`); + const iterationPathEls = computedTimingPathEl.querySelectorAll( + ".animation-iteration-path" + ); + is( + iterationPathEls.length, + expectedIterationPathList.length, + `Number of iteration path should be ${expectedIterationPathList.length}` + ); + + for (const [j, iterationPathEl] of iterationPathEls.entries()) { + assertPathSegments(iterationPathEl, true, expectedIterationPathList[j]); + } + } +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_negative-end-delay-path.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_negative-end-delay-path.js new file mode 100644 index 0000000000..69ce5007b5 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_negative-end-delay-path.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following NegativeEndDelayPath component works. +// * element existance +// * path + +const TEST_DATA = [ + { + targetClass: "enddelay-positive", + }, + { + targetClass: "enddelay-negative", + expectedPath: [ + { x: 500000, y: 0 }, + { x: 500000, y: 50 }, + { x: 750000, y: 75 }, + { x: 1000000, y: 100 }, + { x: 1000000, y: 0 }, + ], + }, +]; + +add_task(async function () { + await addTab(URL_ROOT + "doc_multi_timings.html"); + await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`)); + const { panel } = await openAnimationInspector(); + + for (const { targetClass, expectedPath } of TEST_DATA) { + const animationItemEl = await findAnimationItemByTargetSelector( + panel, + `.${targetClass}` + ); + + info(`Checking negative endDelay path existance for ${targetClass}`); + const negativeEndDelayPathEl = animationItemEl.querySelector( + ".animation-negative-end-delay-path" + ); + + if (expectedPath) { + ok( + negativeEndDelayPathEl, + "The negative endDelay path element should be in animation item element" + ); + const pathEl = negativeEndDelayPathEl.querySelector("path"); + assertPathSegments(pathEl, true, expectedPath); + } else { + ok( + !negativeEndDelayPathEl, + "The negative endDelay path element should not be in animation item element" + ); + } + } +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_summary-graph_tooltip.js b/devtools/client/inspector/animation/test/browser_animation_summary-graph_tooltip.js new file mode 100644 index 0000000000..1be3c92f4f --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_summary-graph_tooltip.js @@ -0,0 +1,294 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for existance and content of tooltip on summary graph element. + +const TEST_DATA = [ + { + targetClass: "cssanimation-normal", + expectedResult: { + nameAndType: "cssanimation — CSS Animation", + duration: "1,000s", + }, + }, + { + targetClass: "cssanimation-linear", + expectedResult: { + nameAndType: "cssanimation — CSS Animation", + duration: "1,000s", + animationTimingFunction: "linear", + }, + }, + { + targetClass: "delay-positive", + expectedResult: { + nameAndType: "test-delay-animation — Script Animation", + delay: "500s", + duration: "1,000s", + }, + }, + { + targetClass: "delay-negative", + expectedResult: { + nameAndType: "test-negative-delay-animation — Script Animation", + delay: "-500s", + duration: "1,000s", + }, + }, + { + targetClass: "easing-step", + expectedResult: { + nameAndType: "Script Animation", + duration: "1,000s", + easing: "steps(2)", + }, + }, + { + targetClass: "enddelay-positive", + expectedResult: { + nameAndType: "Script Animation", + duration: "1,000s", + endDelay: "500s", + }, + }, + { + targetClass: "enddelay-negative", + expectedResult: { + nameAndType: "Script Animation", + duration: "1,000s", + endDelay: "-500s", + }, + }, + { + targetClass: "enddelay-with-fill-forwards", + expectedResult: { + nameAndType: "Script Animation", + duration: "1,000s", + endDelay: "500s", + fill: "forwards", + }, + }, + { + targetClass: "enddelay-with-iterations-infinity", + expectedResult: { + nameAndType: "Script Animation", + duration: "1,000s", + endDelay: "500s", + iterations: "\u221E", + }, + }, + { + targetClass: "direction-alternate-with-iterations-infinity", + expectedResult: { + nameAndType: "Script Animation", + duration: "1,000s", + direction: "alternate", + iterations: "\u221E", + }, + }, + { + targetClass: "direction-alternate-reverse-with-iterations-infinity", + expectedResult: { + nameAndType: "Script Animation", + duration: "1,000s", + direction: "alternate-reverse", + iterations: "\u221E", + }, + }, + { + targetClass: "direction-reverse-with-iterations-infinity", + expectedResult: { + nameAndType: "Script Animation", + duration: "1,000s", + direction: "reverse", + iterations: "\u221E", + }, + }, + { + targetClass: "fill-backwards", + expectedResult: { + nameAndType: "Script Animation", + duration: "1,000s", + fill: "backwards", + }, + }, + { + targetClass: "fill-backwards-with-delay-iterationstart", + expectedResult: { + nameAndType: "Script Animation", + delay: "500s", + duration: "1,000s", + fill: "backwards", + iterationStart: "0.5", + }, + }, + { + targetClass: "fill-both", + expectedResult: { + nameAndType: "Script Animation", + duration: "1,000s", + fill: "both", + }, + }, + { + targetClass: "fill-both-width-delay-iterationstart", + expectedResult: { + nameAndType: "Script Animation", + delay: "500s", + duration: "1,000s", + fill: "both", + iterationStart: "0.5", + }, + }, + { + targetClass: "fill-forwards", + expectedResult: { + nameAndType: "Script Animation", + duration: "1,000s", + fill: "forwards", + }, + }, + { + targetClass: "iterationstart", + expectedResult: { + nameAndType: "Script Animation", + duration: "1,000s", + iterationStart: "0.5", + }, + }, + { + targetClass: "no-compositor", + expectedResult: { + nameAndType: "Script Animation", + duration: "1,000s", + }, + }, + { + targetClass: "keyframes-easing-step", + expectedResult: { + nameAndType: "Script Animation", + duration: "1,000s", + }, + }, +]; + +add_task(async function () { + await addTab(URL_ROOT + "doc_multi_timings.html"); + await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`)); + const { panel } = await openAnimationInspector(); + + for (const { targetClass, expectedResult } of TEST_DATA) { + const animationItemEl = await findAnimationItemByTargetSelector( + panel, + `.${targetClass}` + ); + const summaryGraphEl = animationItemEl.querySelector( + ".animation-summary-graph" + ); + + info(`Checking tooltip for ${targetClass}`); + ok( + summaryGraphEl.hasAttribute("title"), + "Summary graph should have 'title' attribute" + ); + + const tooltip = summaryGraphEl.getAttribute("title"); + const { + animationTimingFunction, + delay, + easing, + endDelay, + direction, + duration, + fill, + iterations, + iterationStart, + nameAndType, + } = expectedResult; + + ok( + tooltip.startsWith(nameAndType), + "Tooltip should start with name and type" + ); + + if (animationTimingFunction) { + const expected = `Animation timing function: ${animationTimingFunction}`; + ok(tooltip.includes(expected), `Tooltip should include '${expected}'`); + } else { + ok( + !tooltip.includes("Animation timing function:"), + "Tooltip should not include animation timing function" + ); + } + + if (delay) { + const expected = `Delay: ${delay}`; + ok(tooltip.includes(expected), `Tooltip should include '${expected}'`); + } else { + ok(!tooltip.includes("Delay:"), "Tooltip should not include delay"); + } + + if (direction) { + const expected = `Direction: ${direction}`; + ok(tooltip.includes(expected), `Tooltip should include '${expected}'`); + } else { + ok(!tooltip.includes("Direction:"), "Tooltip should not include delay"); + } + + if (duration) { + const expected = `Duration: ${duration}`; + ok(tooltip.includes(expected), `Tooltip should include '${expected}'`); + } else { + ok(!tooltip.includes("Duration:"), "Tooltip should not include delay"); + } + + if (easing) { + const expected = `Overall easing: ${easing}`; + ok(tooltip.includes(expected), `Tooltip should include '${expected}'`); + } else { + ok( + !tooltip.includes("Overall easing:"), + "Tooltip should not include easing" + ); + } + + if (endDelay) { + const expected = `End delay: ${endDelay}`; + ok(tooltip.includes(expected), `Tooltip should include '${expected}'`); + } else { + ok( + !tooltip.includes("End delay:"), + "Tooltip should not include endDelay" + ); + } + + if (fill) { + const expected = `Fill: ${fill}`; + ok(tooltip.includes(expected), `Tooltip should include '${expected}'`); + } else { + ok(!tooltip.includes("Fill:"), "Tooltip should not include fill"); + } + + if (iterations) { + const expected = `Repeats: ${iterations}`; + ok(tooltip.includes(expected), `Tooltip should include '${expected}'`); + } else { + ok( + !tooltip.includes("Repeats:"), + "Tooltip should not include iterations" + ); + } + + if (iterationStart) { + const expected = `Iteration start: ${iterationStart}`; + ok(tooltip.includes(expected), `Tooltip should include '${expected}'`); + } else { + ok( + !tooltip.includes("Iteration start:"), + "Tooltip should not include iterationStart" + ); + } + } +}); diff --git a/devtools/client/inspector/animation/test/browser_animation_timing_negative-playback-rate_current-time-scrubber.js b/devtools/client/inspector/animation/test/browser_animation_timing_negative-playback-rate_current-time-scrubber.js new file mode 100644 index 0000000000..f9906329ea --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_timing_negative-playback-rate_current-time-scrubber.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether the scrubber was working in case of negative playback rate. + +add_task(async function () { + await addTab(URL_ROOT + "doc_negative_playback_rate.html"); + await removeAnimatedElementsExcept([".normal"]); + const { animationInspector, panel } = await openAnimationInspector(); + + info("Set initial state"); + clickOnCurrentTimeScrubberController(animationInspector, panel, 0); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + const initialCurrentTime = + animationInspector.state.animations[0].state.currentTime; + const initialProgressBarX = getProgressBarX(panel); + + info("Check whether the animation currentTime was decreased"); + clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5); + await waitUntilCurrentTimeChangedAt( + animationInspector, + animationInspector.state.timeScale.getDuration() * 0.5 + ); + ok( + initialCurrentTime > + animationInspector.state.animations[0].state.currentTime, + "currentTime should be decreased" + ); + + info("Check whether the progress bar was moved to left"); + ok( + initialProgressBarX > getProgressBarX(panel), + "Progress bar should be moved to left" + ); +}); + +function getProgressBarX(panel) { + const areaEl = panel.querySelector(".keyframes-progress-bar-area"); + const barEl = areaEl.querySelector(".keyframes-progress-bar"); + const controllerBounds = areaEl.getBoundingClientRect(); + const barBounds = barEl.getBoundingClientRect(); + const barX = barBounds.x + barBounds.width / 2 - controllerBounds.x; + return barX; +} diff --git a/devtools/client/inspector/animation/test/browser_animation_timing_negative-playback-rate_summary-graph.js b/devtools/client/inspector/animation/test/browser_animation_timing_negative-playback-rate_summary-graph.js new file mode 100644 index 0000000000..ef326f5eb2 --- /dev/null +++ b/devtools/client/inspector/animation/test/browser_animation_timing_negative-playback-rate_summary-graph.js @@ -0,0 +1,235 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for following summary graph with the animation which is negative playback rate. +// * Tooltips +// * Graph path +// * Delay sign +// * End delay sign + +const TEST_DATA = [ + { + targetSelector: ".normal", + expectedPathList: [ + { + selector: ".animation-iteration-path", + path: [ + { x: 0, y: 100 }, + { x: 50000, y: 50 }, + { x: 100000, y: 0 }, + ], + }, + ], + expectedTooltip: "Playback rate: -1", + expectedViewboxWidth: 200000, + }, + { + targetSelector: ".normal-playbackrate-2", + expectedPathList: [ + { + selector: ".animation-iteration-path", + path: [ + { x: 0, y: 100 }, + { x: 50000, y: 50 }, + { x: 100000, y: 0 }, + ], + }, + ], + expectedTooltip: "Playback rate: -2", + expectedViewboxWidth: 400000, + }, + { + targetSelector: ".positive-delay", + expectedSignList: [ + { + selector: ".animation-end-delay-sign", + sign: { + marginInlineStart: "75%", + width: "25%", + }, + }, + ], + expectedPathList: [ + { + selector: ".animation-iteration-path", + path: [ + { x: 0, y: 100 }, + { x: 50000, y: 50 }, + { x: 100000, y: 0 }, + ], + }, + ], + expectedTooltip: "Playback rate: -1", + }, + { + targetSelector: ".negative-delay", + expectedSignList: [ + { + selector: ".animation-end-delay-sign", + sign: { + marginInlineStart: "50%", + width: "25%", + }, + }, + ], + expectedPathList: [ + { + selector: ".animation-iteration-path", + path: [ + { x: 0, y: 100 }, + { x: 50000, y: 50 }, + { x: 50000, y: 0 }, + ], + }, + { + selector: ".animation-negative-delay-path path", + path: [ + { x: 50000, y: 50 }, + { x: 100000, y: 0 }, + ], + }, + ], + expectedTooltip: "Playback rate: -1", + }, + { + targetSelector: ".positive-end-delay", + expectedSignList: [ + { + selector: ".animation-delay-sign", + sign: { + isFilled: true, + marginInlineStart: "25%", + width: "25%", + }, + }, + ], + expectedPathList: [ + { + selector: ".animation-iteration-path", + path: [ + { x: 50000, y: 100 }, + { x: 100000, y: 50 }, + { x: 150000, y: 0 }, + ], + }, + ], + expectedTooltip: "Playback rate: -1", + }, + { + targetSelector: ".negative-end-delay", + expectedSignList: [ + { + selector: ".animation-delay-sign", + sign: { + isFilled: true, + marginInlineStart: "0%", + width: "25%", + }, + }, + ], + expectedPathList: [ + { + selector: ".animation-iteration-path", + path: [ + { x: 0, y: 50 }, + { x: 50000, y: 0 }, + ], + }, + { + selector: ".animation-negative-end-delay-path path", + path: [ + { x: -50000, y: 100 }, + { x: 0, y: 0 }, + ], + }, + ], + expectedTooltip: "Playback rate: -1", + }, +]; + +add_task(async function () { + await addTab(URL_ROOT + "doc_negative_playback_rate.html"); + const { panel } = await openAnimationInspector(); + + for (const testData of TEST_DATA) { + const { + targetSelector, + expectedPathList, + expectedSignList, + expectedTooltip, + expectedViewboxWidth, + } = testData; + + const animationItemEl = await findAnimationItemByTargetSelector( + panel, + targetSelector + ); + const summaryGraphEl = animationItemEl.querySelector( + ".animation-summary-graph" + ); + + info(`Check tooltip for the animation of ${targetSelector}`); + assertTooltip(summaryGraphEl, expectedTooltip); + + if (expectedPathList) { + for (const { selector, path } of expectedPathList) { + info(`Check path for ${selector}`); + assertPath(summaryGraphEl, selector, path); + } + } + + if (expectedSignList) { + for (const { selector, sign } of expectedSignList) { + info(`Check sign for ${selector}`); + assertSign(summaryGraphEl, selector, sign); + } + } + + if (expectedViewboxWidth) { + info("Check width of viewbox of SVG"); + const svgEl = summaryGraphEl.querySelector( + ".animation-summary-graph-path" + ); + is( + svgEl.viewBox.baseVal.width, + expectedViewboxWidth, + `width of viewbox should be ${expectedViewboxWidth}` + ); + } + } +}); + +function assertPath(summaryGraphEl, pathSelector, expectedPath) { + const pathEl = summaryGraphEl.querySelector(pathSelector); + assertPathSegments(pathEl, true, expectedPath); +} + +function assertSign(summaryGraphEl, selector, expectedSign) { + const signEl = summaryGraphEl.querySelector(selector); + + is( + signEl.style.marginInlineStart, + expectedSign.marginInlineStart, + `marginInlineStart position should be ${expectedSign.marginInlineStart}` + ); + is( + signEl.style.width, + expectedSign.width, + `Width should be ${expectedSign.width}` + ); + is( + signEl.classList.contains("fill"), + expectedSign.isFilled || false, + "signEl should be correct" + ); +} + +function assertTooltip(summaryGraphEl, expectedTooltip) { + const tooltip = summaryGraphEl.getAttribute("title"); + ok( + tooltip.includes(expectedTooltip), + `Tooltip should include '${expectedTooltip}'` + ); +} diff --git a/devtools/client/inspector/animation/test/current-time-scrubber_head.js b/devtools/client/inspector/animation/test/current-time-scrubber_head.js new file mode 100644 index 0000000000..1e94a7562c --- /dev/null +++ b/devtools/client/inspector/animation/test/current-time-scrubber_head.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +// Test for following CurrentTimeScrubber and CurrentTimeScrubberController components: +// * element existence +// * scrubber position validity +// * make animations currentTime to change by click on the controller +// * mouse drag on the scrubber + +// eslint-disable-next-line no-unused-vars +async function testCurrentTimeScrubber(isRTL) { + await addTab(URL_ROOT + "doc_simple_animation.html"); + await removeAnimatedElementsExcept([".long"]); + const { animationInspector, panel } = await openAnimationInspector(); + + info("Checking scrubber controller existence"); + const controllerEl = panel.querySelector(".current-time-scrubber-area"); + ok(controllerEl, "scrubber controller should exist"); + + info("Checking scrubber existence"); + const scrubberEl = controllerEl.querySelector(".current-time-scrubber"); + ok(scrubberEl, "scrubber should exist"); + + info("Checking scrubber changes current time of animation and the position"); + const duration = animationInspector.state.timeScale.getDuration(); + clickOnCurrentTimeScrubberController( + animationInspector, + panel, + isRTL ? 1 : 0 + ); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + await waitUntilCurrentTimeChangedAt(animationInspector, 0); + assertPosition( + scrubberEl, + controllerEl, + isRTL ? duration : 0, + animationInspector + ); + + clickOnCurrentTimeScrubberController( + animationInspector, + panel, + isRTL ? 0 : 1 + ); + await waitUntilCurrentTimeChangedAt(animationInspector, duration); + assertPosition( + scrubberEl, + controllerEl, + isRTL ? 0 : duration, + animationInspector + ); + + clickOnCurrentTimeScrubberController(animationInspector, panel, 0.5); + await waitUntilCurrentTimeChangedAt(animationInspector, duration * 0.5); + assertPosition(scrubberEl, controllerEl, duration * 0.5, animationInspector); + + info("Checking current time scrubber position during running"); + // Running again + clickOnPauseResumeButton(animationInspector, panel); + await waitUntilAnimationsPlayState(animationInspector, "running"); + let previousX = scrubberEl.getBoundingClientRect().x; + await wait(1000); + let currentX = scrubberEl.getBoundingClientRect().x; + isnot(previousX, currentX, "Scrubber should be moved"); + + info("Checking draggable on scrubber over animation list"); + clickOnPauseResumeButton(animationInspector, panel); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + previousX = scrubberEl.getBoundingClientRect().x; + await dragOnCurrentTimeScrubber(animationInspector, panel, 5, 30); + currentX = scrubberEl.getBoundingClientRect().x; + isnot(previousX, currentX, "Scrubber should be draggable"); + + info( + "Checking a behavior which mouse out from animation inspector area " + + "during dragging from controller" + ); + await dragOnCurrentTimeScrubberController(animationInspector, panel, 0.5, 2); + ok( + !panel + .querySelector(".animation-list-container") + .classList.contains("active-scrubber"), + "Click and DnD should be inactive" + ); +} + +function assertPosition(scrubberEl, controllerEl, time, animationInspector) { + const controllerBounds = controllerEl.getBoundingClientRect(); + const scrubberBounds = scrubberEl.getBoundingClientRect(); + const scrubberX = + scrubberBounds.x + scrubberBounds.width / 2 - controllerBounds.x; + const timeScale = animationInspector.state.timeScale; + const expected = Math.round( + (time / timeScale.getDuration()) * controllerBounds.width + ); + is(scrubberX, expected, `Position should be ${expected} at ${time}ms`); +} diff --git a/devtools/client/inspector/animation/test/doc_custom_playback_rate.html b/devtools/client/inspector/animation/test/doc_custom_playback_rate.html new file mode 100644 index 0000000000..9adee99884 --- /dev/null +++ b/devtools/client/inspector/animation/test/doc_custom_playback_rate.html @@ -0,0 +1,30 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <style> + div { + background-color: lime; + height: 100px; + } + </style> + </head> + <body> + <script> + "use strict"; + + const duration = 100000; + + function createAnimation(cls) { + const div = document.createElement("div"); + div.classList.add(cls); + document.body.appendChild(div); + const animation = div.animate([{ opacity: 0 }], duration); + animation.playbackRate = 1.5; + } + + createAnimation("div1"); + createAnimation("div2"); + </script> + </body> +</html> diff --git a/devtools/client/inspector/animation/test/doc_infinity_duration.html b/devtools/client/inspector/animation/test/doc_infinity_duration.html new file mode 100644 index 0000000000..10d19fc3cf --- /dev/null +++ b/devtools/client/inspector/animation/test/doc_infinity_duration.html @@ -0,0 +1,41 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <style> + div { + background-color: lime; + height: 100px; + } + </style> + </head> + <body> + <div class="infinity"></div> + <div class="infinity-delay-iteration-start"></div> + <div class="limited"></div> + <script> + "use strict"; + + document.querySelector(".infinity").animate( + { opacity: [1, 0] }, + { duration: Infinity } + ); + + document.querySelector(".infinity-delay-iteration-start").animate( + { opacity: [1, 0] }, + { + delay: 100000, + duration: Infinity, + iterationStart: 0.5, + } + ); + + document.querySelector(".limited").animate( + { opacity: [1, 0] }, + { + duration: 100000, + } + ); + </script> + </body> +</html> diff --git a/devtools/client/inspector/animation/test/doc_multi_easings.html b/devtools/client/inspector/animation/test/doc_multi_easings.html new file mode 100644 index 0000000000..cedcb027fe --- /dev/null +++ b/devtools/client/inspector/animation/test/doc_multi_easings.html @@ -0,0 +1,121 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <style> + div { + background-color: lime; + height: 50px; + } + </style> + </head> + <body> + <script> + "use strict"; + + function createAnimation(name, keyframes, effectEasing) { + const div = document.createElement("div"); + div.classList.add(name); + document.body.appendChild(div); + + const effect = { + duration: 100000, + fill: "forwards", + }; + + if (effectEasing) { + effect.easing = effectEasing; + } + + div.animate(keyframes, effect); + } + + createAnimation( + "no-easing", + [ + { opacity: 1 }, + { opacity: 0 }, + ] + ); + + createAnimation( + "effect-easing", + [ + { opacity: 1 }, + { opacity: 0 }, + ], + "steps(5, jump-none)" + ); + + createAnimation( + "keyframe-easing", + [ + { opacity: 1, easing: "steps(2)" }, + { opacity: 0 }, + ] + ); + + createAnimation( + "both-easing", + [ + { offset: 0, opacity: 1, easing: "steps(2)" }, + { offset: 0, marginLeft: "0px", easing: "steps(1)" }, + { marginLeft: "100px", opacity: 0 }, + ], + "steps(10)" + ); + + createAnimation( + "narrow-keyframes", + [ + { opacity: 0 }, + { offset: 0.1, opacity: 1, easing: "steps(1)" }, + { offset: 0.13, opacity: 0 }, + ] + ); + + createAnimation( + "duplicate-keyframes", + [ + { opacity: 0 }, + { offset: 0.5, opacity: 1 }, + { offset: 0.5, opacity: 0, easing: "steps(1)" }, + { opacity: 1 }, + ] + ); + + createAnimation( + "color-keyframes", + [ + { color: "red", easing: "ease-in" }, + { offset: 0.4, color: "blue", easing: "ease-out" }, + { color: "lime" }, + ] + ); + + createAnimation( + "jump-start", + [ + { opacity: 1, easing: "steps(2, jump-start)" }, + { opacity: 0 }, + ], + ); + + createAnimation( + "jump-end", + [ + { opacity: 1, easing: "steps(2, jump-end)" }, + { opacity: 0 }, + ], + ); + + createAnimation( + "jump-both", + [ + { opacity: 1, easing: "steps(3, jump-both)" }, + { opacity: 0 }, + ], + ); + </script> + </body> +</html> diff --git a/devtools/client/inspector/animation/test/doc_multi_keyframes.html b/devtools/client/inspector/animation/test/doc_multi_keyframes.html new file mode 100644 index 0000000000..8977f77dde --- /dev/null +++ b/devtools/client/inspector/animation/test/doc_multi_keyframes.html @@ -0,0 +1,229 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <style> + div { + width: 50px; + height: 50px; + } + </style> + </head> + <body> + <script> + "use strict"; + + function createAnimation(name, keyframes, effectEasing) { + const div = document.createElement("div"); + div.classList.add(name); + document.body.appendChild(div); + + const effect = { + duration: 100000, + fill: "forwards", + }; + + if (effectEasing) { + effect.easing = effectEasing; + } + + div.animate(keyframes, effect); + } + + createAnimation( + "multi-types", + [ + { + backgroundColor: "red", + backgroundRepeat: "space round", + fontSize: "10px", + marginLeft: "0px", + opacity: 0, + textAlign: "right", + transform: "translate(0px)", + }, + { + backgroundColor: "lime", + backgroundRepeat: "round space", + fontSize: "20px", + marginLeft: "100px", + opacity: 1, + textAlign: "center", + transform: "translate(100px)", + }, + ] + ); + + createAnimation( + "multi-types-reverse", + [ + { + backgroundColor: "lime", + backgroundRepeat: "space", + fontSize: "20px", + marginLeft: "100px", + opacity: 1, + textAlign: "center", + transform: "translate(100px)", + }, + { + backgroundColor: "red", + backgroundRepeat: "round", + fontSize: "10px", + marginLeft: "0px", + opacity: 0, + textAlign: "right", + transform: "translate(0px)", + }, + ] + ); + + createAnimation( + "middle-keyframe", + [ + { + backgroundColor: "red", + backgroundRepeat: "space", + fontSize: "10px", + marginLeft: "0px", + opacity: 0, + textAlign: "right", + transform: "translate(0px)", + }, + { + backgroundColor: "blue", + backgroundRepeat: "round", + fontSize: "20px", + marginLeft: "100px", + opacity: 1, + textAlign: "center", + transform: "translate(100px)", + }, + { + backgroundColor: "lime", + backgroundRepeat: "space", + fontSize: "10px", + marginLeft: "0px", + opacity: 0, + textAlign: "right", + transform: "translate(0px)", + }, + ] + ); + + createAnimation( + "steps-keyframe", + [ + { + backgroundColor: "red", + backgroundRepeat: "space", + fontSize: "10px", + marginLeft: "0px", + opacity: 0, + textAlign: "right", + transform: "translate(0px)", + easing: "steps(2)", + }, + { + backgroundColor: "lime", + backgroundRepeat: "round", + fontSize: "20px", + marginLeft: "100px", + opacity: 1, + textAlign: "center", + transform: "translate(100px)", + }, + ] + ); + + createAnimation( + "steps-effect", + [ + { + opacity: 0, + }, + { + opacity: 1, + }, + ], + "steps(2)" + ); + + createAnimation( + "steps-jump-none-keyframe", + [ + { + easing: "steps(5, jump-none)", + opacity: 0, + }, + { + opacity: 1, + }, + ] + ); + + createAnimation( + "narrow-offsets", + [ + { + opacity: 0, + }, + { + opacity: 1, + easing: "steps(2)", + offset: 0.1, + }, + { + opacity: 0, + offset: 0.13, + }, + ] + ); + + createAnimation( + "duplicate-offsets", + [ + { + opacity: 1, + }, + { + opacity: 1, + offset: 0.5, + }, + { + opacity: 0, + offset: 0.5, + }, + { + opacity: 1, + offset: 1, + }, + ] + ); + + createAnimation( + "same-color", + [ + { + backgroundColor: "lime", + }, + { + backgroundColor: "lime", + }, + ] + ); + + createAnimation( + "currentcolor", + [ + { + backgroundColor: "currentColor", + }, + { + backgroundColor: "lime", + }, + ] + ); + </script> + </body> +</html> diff --git a/devtools/client/inspector/animation/test/doc_multi_timings.html b/devtools/client/inspector/animation/test/doc_multi_timings.html new file mode 100644 index 0000000000..a999431917 --- /dev/null +++ b/devtools/client/inspector/animation/test/doc_multi_timings.html @@ -0,0 +1,169 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <style> + div { + background-color: lime; + height: 100px; + width: 100px; + } + + .cssanimation-normal { + animation: cssanimation 1000s; + } + + .cssanimation-linear { + animation: cssanimation 1000s linear; + } + + @keyframes cssanimation { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + </style> + </head> + <body> + <div class="cssanimation-normal"></div> + <div class="cssanimation-linear"></div> + <script> + "use strict"; + + const duration = 1000000; + + function createAnimation(keyframes, effect, className) { + const div = document.createElement("div"); + div.classList.add(className); + document.body.appendChild(div); + effect.duration = duration; + div.animate(keyframes, effect); + } + + createAnimation({ opacity: [0, 1] }, + { delay: 500000, id: "test-delay-animation" }, + "delay-positive"); + + createAnimation({ opacity: [0, 1] }, + { delay: -500000, id: "test-negative-delay-animation" }, + "delay-negative"); + + createAnimation({ opacity: [0, 1] }, + { easing: "steps(2)" }, + "easing-step"); + + createAnimation({ opacity: [0, 1] }, + { endDelay: 500000 }, + "enddelay-positive"); + + createAnimation({ opacity: [0, 1] }, + { endDelay: -500000 }, + "enddelay-negative"); + + createAnimation({ opacity: [0, 1] }, + { endDelay: 500000, fill: "forwards" }, + "enddelay-with-fill-forwards"); + + createAnimation({ opacity: [0, 1] }, + { endDelay: 500000, iterations: Infinity }, + "enddelay-with-iterations-infinity"); + + createAnimation({ opacity: [0, 1] }, + { direction: "alternate", iterations: Infinity }, + "direction-alternate-with-iterations-infinity"); + + createAnimation({ opacity: [0, 1] }, + { direction: "alternate-reverse", iterations: Infinity }, + "direction-alternate-reverse-with-iterations-infinity"); + + createAnimation({ opacity: [0, 1] }, + { direction: "reverse", iterations: Infinity }, + "direction-reverse-with-iterations-infinity"); + + createAnimation({ opacity: [0, 1] }, + { fill: "backwards" }, + "fill-backwards"); + + createAnimation({ opacity: [0, 1] }, + { fill: "backwards", delay: 500000, iterationStart: 0.5 }, + "fill-backwards-with-delay-iterationstart"); + + createAnimation({ opacity: [0, 1] }, + { fill: "both" }, + "fill-both"); + + createAnimation({ opacity: [0, 1] }, + { fill: "both", delay: 500000, iterationStart: 0.5 }, + "fill-both-width-delay-iterationstart"); + + createAnimation({ opacity: [0, 1] }, + { fill: "forwards" }, + "fill-forwards"); + + createAnimation({ opacity: [0, 1] }, + { iterationStart: 0.5 }, + "iterationstart"); + + createAnimation({ width: ["100px", "150px"] }, + {}, + "no-compositor"); + + createAnimation([{ opacity: 0, easing: "steps(2)" }, { opacity: 1 }], + {}, + "keyframes-easing-step"); + + createAnimation( + [ + { + opacity: 0, + offset: 0, + }, + { + opacity: 1, + offset: 0.1, + easing: "steps(1)", + }, + { + opacity: 0, + offset: 0.13, + }, + ], + {}, + "narrow-keyframes"); + + createAnimation( + [ + { + offset: 0, + opacity: 1, + }, + { + offset: 0.5, + opacity: 1, + }, + { + offset: 0.5, + easing: "steps(1)", + opacity: 0, + }, + { + offset: 1, + opacity: 1, + }, + ], + {}, + "duplicate-offsets"); + + createAnimation({ opacity: [0, 1] }, + { delay: -250000 }, + "delay-negative-25"); + + createAnimation({ opacity: [0, 1] }, + { delay: -750000 }, + "delay-negative-75"); + </script> + </body> +</html> diff --git a/devtools/client/inspector/animation/test/doc_mutations_add_remove_immediately.html b/devtools/client/inspector/animation/test/doc_mutations_add_remove_immediately.html new file mode 100644 index 0000000000..c8b3db749b --- /dev/null +++ b/devtools/client/inspector/animation/test/doc_mutations_add_remove_immediately.html @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + </head> + <body> + <div></div> + + <script> + "use strict"; + + // This function is called from test. + // eslint-disable-next-line + function startMutation() { + const target = document.querySelector("div"); + const animation = target.animate({ opacity: [1, 0] }, 100000); + animation.currentTime = 1; + animation.cancel(); + } + </script> + </body> +</html> diff --git a/devtools/client/inspector/animation/test/doc_mutations_fast.html b/devtools/client/inspector/animation/test/doc_mutations_fast.html new file mode 100644 index 0000000000..3622846953 --- /dev/null +++ b/devtools/client/inspector/animation/test/doc_mutations_fast.html @@ -0,0 +1,53 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <style> + div { + background-color: lime; + height: 20px; + opacity: 1; + transition: 0.5s opacity; + } + + .transition { + opacity: 0; + } + </style> + </head> + <body> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + + <script> + "use strict"; + + // This function is called from test. + // eslint-disable-next-line + async function startFastMutations() { + const targets = document.querySelectorAll("div"); + + for (let i = 0; i < 10; i++) { + for (const target of targets) { + target.classList.toggle("transition"); + await wait(15); + } + } + } + + async function wait(ms) { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); + } + </script> + </body> +</html> diff --git a/devtools/client/inspector/animation/test/doc_negative_playback_rate.html b/devtools/client/inspector/animation/test/doc_negative_playback_rate.html new file mode 100644 index 0000000000..a98700712d --- /dev/null +++ b/devtools/client/inspector/animation/test/doc_negative_playback_rate.html @@ -0,0 +1,38 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <style> + div { + background-color: lime; + height: 100px; + } + </style> + </head> + <body> + <script> + "use strict"; + + const DURATION = 100000; + const KEYFRAMES = { backgroundColor: ["lime", "red"] }; + + function createAnimation(effect, className, playbackRate = -1) { + const div = document.createElement("div"); + div.classList.add(className); + document.body.appendChild(div); + effect.duration = DURATION; + effect.fill = "forwards"; + const animation = div.animate(KEYFRAMES, effect); + animation.updatePlaybackRate(playbackRate); + animation.play(); + } + + createAnimation({}, "normal"); + createAnimation({}, "normal-playbackrate-2", -2); + createAnimation({ delay: 50000 }, "positive-delay"); + createAnimation({ delay: -50000 }, "negative-delay"); + createAnimation({ endDelay: 50000 }, "positive-end-delay"); + createAnimation({ endDelay: -50000 }, "negative-end-delay"); + </script> + </body> +</html> diff --git a/devtools/client/inspector/animation/test/doc_overflowed_delay_end_delay.html b/devtools/client/inspector/animation/test/doc_overflowed_delay_end_delay.html new file mode 100644 index 0000000000..a4d91ae4ef --- /dev/null +++ b/devtools/client/inspector/animation/test/doc_overflowed_delay_end_delay.html @@ -0,0 +1,75 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <style> + div { + width: 100px; + height: 100px; + outline: 1px solid lime; + } + </style> + </head> + <body> + <div id="target"></div> + <script> + "use strict"; + + const target = document.getElementById("target"); + target.animate( + { + color: ["red", "lime"], + }, + { + id: "big-delay", + duration: 1000, + delay: Number.MAX_VALUE, + iterations: Infinity, + }); + + target.animate( + { + opacity: [1, 0], + }, + { + id: "big-end-delay", + duration: 1000, + endDelay: Number.MAX_VALUE, + iterations: Infinity, + }); + + target.animate( + { + marginLeft: ["0px", "100px"], + }, + { + id: "negative-big-delay", + duration: 1000, + delay: -Number.MAX_VALUE, + iterations: Infinity, + }); + + target.animate( + { + paddingLeft: ["0px", "100px"], + }, + { + id: "negative-big-end-delay", + duration: 1000, + endDelay: -Number.MAX_VALUE, + iterations: Infinity, + }); + + target.animate( + { + backgroundColor: ["lime", "white"], + }, + { + id: "big-iteration-start", + duration: 1000, + iterations: Infinity, + iterationStart: Number.MAX_VALUE, + }); + </script> + </body> +</html> diff --git a/devtools/client/inspector/animation/test/doc_pseudo.html b/devtools/client/inspector/animation/test/doc_pseudo.html new file mode 100644 index 0000000000..3cc0c93470 --- /dev/null +++ b/devtools/client/inspector/animation/test/doc_pseudo.html @@ -0,0 +1,91 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <style> + body::before { + animation: body 10s infinite; + background-color: lime; + content: "body-before"; + width: 100px; + } + + .div-before::before { + animation: div-before 10s infinite; + background-color: lime; + content: "div-before"; + width: 100px; + } + + .div-after::after { + animation: div-after 10s infinite; + background-color: lime; + content: "div-after"; + width: 100px; + } + + .div-marker { + display: list-item; + list-style-position: inside; + } + + .div-marker::marker { + content: "div-marker"; + } + + @keyframes body { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes div-before { + from { + opacity: 1; + } + to { + opacity: 0; + } + } + + @keyframes div-after { + from { + opacity: 1; + } + 50% { + opacity: 0.9; + } + to { + opacity: 0; + } + } + </style> + </head> + <body> + <div class="div-before"></div> + <div class="div-after"></div> + <div class="div-marker"></div> + + <script> + "use strict"; + + // The reason why we currently run the animation on `::marker` with Web Animations API + // instead of CSS Animations is because it requires `layout.css.marker.restricted` + // pref change. + document.querySelector(".div-marker").animate( + { + color: ["black", "lime"], + }, + { + id: "div-marker", + duration: 10000, + iterations: Infinity, + pseudoElement: "::marker", + } + ); + </script> + </body> +</html> diff --git a/devtools/client/inspector/animation/test/doc_short_duration.html b/devtools/client/inspector/animation/test/doc_short_duration.html new file mode 100644 index 0000000000..ed9b2d94dc --- /dev/null +++ b/devtools/client/inspector/animation/test/doc_short_duration.html @@ -0,0 +1,26 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <style> + div { + background-color: lime; + height: 100px; + } + </style> + </head> + <body> + <div class="short"></div> + <script> + "use strict"; + + document.querySelector(".short").animate( + { opacity: [1, 0] }, + { + duration: 1, + iterations: Infinity, + } + ); + </script> + </body> +</html> diff --git a/devtools/client/inspector/animation/test/doc_simple_animation.html b/devtools/client/inspector/animation/test/doc_simple_animation.html new file mode 100644 index 0000000000..5150c241dd --- /dev/null +++ b/devtools/client/inspector/animation/test/doc_simple_animation.html @@ -0,0 +1,174 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <style> + .ball { + width: 80px; + height: 80px; + /* Add a border here to avoid layout warnings in Linux debug builds: Bug 1329784 */ + border: 1px solid transparent; + border-radius: 50%; + background: #f06; + + position: absolute; + } + + .still { + top: 0; + left: 10px; + } + + .animated { + top: 100px; + left: 10px; + + animation: simple-animation 2s infinite alternate; + } + + .multi { + top: 200px; + left: 10px; + + animation: simple-animation 2s infinite alternate, + other-animation 5s infinite alternate; + } + + .delayed { + top: 300px; + left: 10px; + background: rebeccapurple; + + animation: simple-animation 3s 60s 10; + } + + .multi-finite { + top: 400px; + left: 10px; + background: yellow; + + animation: simple-animation 3s, + other-animation 4s; + } + + .short { + top: 500px; + left: 10px; + background: red; + + animation: simple-animation 2s normal; + } + + .long { + top: 600px; + left: 10px; + background: blue; + + animation: simple-animation 120s; + } + + .negative-delay { + top: 700px; + left: 10px; + background: gray; + + animation: simple-animation 15s -10s; + animation-fill-mode: forwards; + } + + .no-compositor { + top: 0; + right: 10px; + background: gold; + + animation: no-compositor 10s cubic-bezier(.57,-0.02,1,.31) forwards; + } + + .compositor-all { + animation: compositor-all 2s infinite; + } + + .compositor-notall { + animation: compositor-notall 2s infinite; + } + + .longhand { + animation: longhand 10s infinite; + } + + @keyframes simple-animation { + 100% { + transform: translateX(300px); + } + } + + @keyframes other-animation { + 100% { + background: blue; + } + } + + @keyframes no-compositor { + 100% { + margin-right: 600px; + } + } + + @keyframes compositor-all { + to { opacity: 0.5 } + } + + @keyframes compositor-notall { + from { + opacity: 0; + width: 0px; + transform: translate(0px); + } + to { + opacity: 1; + width: 100px; + transform: translate(100px); + } + } + + @keyframes longhand { + from { + background: red; + padding: 0 0 0 10px; + } + to { + background: lime; + padding: 0 0 0 20px; + } + } + </style> +</head> +<body> + <!-- Comment node --> + <div class="ball still"></div> + <div class="ball animated"></div> + <div class="ball multi"></div> + <div class="ball delayed"></div> + <div class="ball multi-finite"></div> + <div class="ball short"></div> + <div class="ball long"></div> + <div class="ball negative-delay"></div> + <div class="ball no-compositor"></div> + <div class="ball end-delay"></div> + <div class="ball compositor-all"></div> + <div class="ball compositor-notall"></div> + <div class="ball longhand"></div> + <script> + /* globals KeyframeEffect, Animation */ + "use strict"; + + const el = document.querySelector(".end-delay"); + const effect = new KeyframeEffect(el, [ + { opacity: 0, offset: 0 }, + { opacity: 1, offset: 1 }, + ], { duration: 1000000, endDelay: 500000, fill: "none" }); + const animation = new Animation(effect, document.timeline); + animation.play(); + </script> +</body> +</html> diff --git a/devtools/client/inspector/animation/test/doc_special_colors.html b/devtools/client/inspector/animation/test/doc_special_colors.html new file mode 100644 index 0000000000..2c71b2c963 --- /dev/null +++ b/devtools/client/inspector/animation/test/doc_special_colors.html @@ -0,0 +1,28 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <style> + div { + animation: anim 5s infinite; + border: 1px solid lime; + height: 100px; + width: 100px; + } + + @keyframes anim { + from { + caret-color: auto; + scrollbar-color: lime red; + } + to { + caret-color: lime; + scrollbar-color: auto; + } + } + </style> + </head> + <body> + <div></div> + </body> +</html> diff --git a/devtools/client/inspector/animation/test/head.js b/devtools/client/inspector/animation/test/head.js new file mode 100644 index 0000000000..33cb52125f --- /dev/null +++ b/devtools/client/inspector/animation/test/head.js @@ -0,0 +1,1038 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint no-unused-vars: [2, {"vars": "local", "args": "none"}] */ + +"use strict"; + +// Import the inspector's head.js first (which itself imports shared-head.js). +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/head.js", + this +); + +const TAB_NAME = "animationinspector"; + +const ANIMATION_L10N = new LocalizationHelper( + "devtools/client/locales/animationinspector.properties" +); + +// Auto clean-up when a test ends. +// Clean-up all prefs that might have been changed during a test run +// (safer here because if the test fails, then the pref is never reverted) +registerCleanupFunction(() => { + Services.prefs.clearUserPref("devtools.toolsidebar-width.inspector"); +}); + +/** + * Open the toolbox, with the inspector tool visible and the animationinspector + * sidebar selected. + * + * @return {Promise} that resolves when the inspector is ready. + */ +const openAnimationInspector = async function () { + const { inspector, toolbox } = await openInspectorSidebarTab(TAB_NAME); + await inspector.once("inspector-updated"); + const animationInspector = inspector.getPanel("animationinspector"); + const panel = inspector.panelWin.document.getElementById( + "animation-container" + ); + + info("Wait for loading first content"); + const count = getDisplayedGraphCount(animationInspector, panel); + await waitUntil( + () => + panel.querySelectorAll(".animation-summary-graph-path").length >= count && + panel.querySelectorAll(".animation-target .objectBox").length >= count + ); + + if ( + animationInspector.state.selectedAnimation && + animationInspector.state.detailVisibility + ) { + await waitUntil(() => panel.querySelector(".animated-property-list")); + } + + return { animationInspector, toolbox, inspector, panel }; +}; + +/** + * Close the toolbox. + * + * @return {Promise} that resolves when the toolbox has closed. + */ +const closeAnimationInspector = async function () { + return gDevTools.closeToolboxForTab(gBrowser.selectedTab); +}; + +/** + * Some animation features are not enabled by default in release/beta channels + * yet including parts of the Web Animations API. + */ +const enableAnimationFeatures = function () { + return new Promise(resolve => { + SpecialPowers.pushPrefEnv( + { + set: [ + ["dom.animations-api.core.enabled", true], + ["dom.animations-api.getAnimations.enabled", true], + ["dom.animations-api.implicit-keyframes.enabled", true], + ["dom.animations-api.timelines.enabled", true], + ["layout.css.step-position-jump.enabled", true], + ], + }, + resolve + ); + }); +}; + +/** + * Add a new test tab in the browser and load the given url. + * + * @param {String} url + * The url to be loaded in the new tab + * @return a promise that resolves to the tab object when the url is loaded + */ +const _addTab = addTab; +addTab = async function (url) { + await enableAnimationFeatures(); + return _addTab(url); +}; + +/** + * Remove animated elements from document except given selectors. + * + * @param {Array} selectors + * @return {Promise} + */ +const removeAnimatedElementsExcept = function (selectors) { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [selectors], + selectorsChild => { + function isRemovableElement(animation, selectorsInner) { + for (const selector of selectorsInner) { + if (animation.effect.target.matches(selector)) { + return false; + } + } + + return true; + } + + for (const animation of content.document.getAnimations()) { + if (isRemovableElement(animation, selectorsChild)) { + animation.effect.target.remove(); + } + } + } + ); +}; + +/** + * Click on an animation in the timeline to select it. + * + * @param {AnimationInspector} animationInspector. + * @param {DOMElement} panel + * #animation-container element. + * @param {Number} index + * The index of the animation to click on. + */ +const clickOnAnimation = async function (animationInspector, panel, index) { + info("Click on animation " + index + " in the timeline"); + const animationItemEl = await findAnimationItemByIndex(panel, index); + const summaryGraphEl = animationItemEl.querySelector( + ".animation-summary-graph" + ); + clickOnSummaryGraph(animationInspector, panel, summaryGraphEl); +}; + +/** + * Click on an animation by given selector of node which is target element of animation. + * + * @param {AnimationInspector} animationInspector. + * @param {DOMElement} panel + * #animation-container element. + * @param {String} selector + * Selector of node which is target element of animation. + */ +const clickOnAnimationByTargetSelector = async function ( + animationInspector, + panel, + selector +) { + info(`Click on animation whose selector of target element is '${selector}'`); + const animationItemEl = await findAnimationItemByTargetSelector( + panel, + selector + ); + const summaryGraphEl = animationItemEl.querySelector( + ".animation-summary-graph" + ); + clickOnSummaryGraph(animationInspector, panel, summaryGraphEl); +}; + +/** + * Click on close button for animation detail pane. + * + * @param {DOMElement} panel + * #animation-container element. + */ +const clickOnDetailCloseButton = function (panel) { + info("Click on close button for animation detail pane"); + const buttonEl = panel.querySelector(".animation-detail-close-button"); + const bounds = buttonEl.getBoundingClientRect(); + const x = bounds.width / 2; + const y = bounds.height / 2; + EventUtils.synthesizeMouse(buttonEl, x, y, {}, buttonEl.ownerGlobal); +}; + +/** + * Click on pause/resume button. + * + * @param {AnimationInspector} animationInspector + * @param {DOMElement} panel + * #animation-container element. + */ +const clickOnPauseResumeButton = function (animationInspector, panel) { + info("Click on pause/resume button"); + const buttonEl = panel.querySelector(".pause-resume-button"); + const bounds = buttonEl.getBoundingClientRect(); + const x = bounds.width / 2; + const y = bounds.height / 2; + EventUtils.synthesizeMouse(buttonEl, x, y, {}, buttonEl.ownerGlobal); +}; + +/** + * Click on rewind button. + * + * @param {AnimationInspector} animationInspector + * @param {DOMElement} panel + * #animation-container element. + */ +const clickOnRewindButton = function (animationInspector, panel) { + info("Click on rewind button"); + const buttonEl = panel.querySelector(".rewind-button"); + const bounds = buttonEl.getBoundingClientRect(); + const x = bounds.width / 2; + const y = bounds.height / 2; + EventUtils.synthesizeMouse(buttonEl, x, y, {}, buttonEl.ownerGlobal); +}; + +/** + * Click on the scrubber controller pane to update the animation current time. + * + * @param {DOMElement} panel + * #animation-container element. + * @param {Number} mouseDownPosition + * rate on scrubber controller pane. + * This method calculates + * `mouseDownPosition * offsetWidth + offsetLeft of scrubber controller pane` + * as the clientX of MouseEvent. + */ +const clickOnCurrentTimeScrubberController = function ( + animationInspector, + panel, + mouseDownPosition +) { + const controllerEl = panel.querySelector(".current-time-scrubber-area"); + const bounds = controllerEl.getBoundingClientRect(); + const mousedonwX = bounds.width * mouseDownPosition; + + info(`Click ${mousedonwX} on scrubber controller`); + EventUtils.synthesizeMouse( + controllerEl, + mousedonwX, + 0, + {}, + controllerEl.ownerGlobal + ); +}; + +/** + * Click on the inspect icon for the given AnimationTargetComponent. + * + * @param {AnimationInspector} animationInspector. + * @param {DOMElement} panel + * #animation-container element. + * @param {Number} index + * The index of the AnimationTargetComponent to click on. + */ +const clickOnInspectIcon = async function (animationInspector, panel, index) { + info(`Click on an inspect icon in animation target component[${index}]`); + const animationItemEl = await findAnimationItemByIndex(panel, index); + const iconEl = animationItemEl.querySelector( + ".animation-target .objectBox .highlight-node" + ); + iconEl.scrollIntoView(false); + EventUtils.synthesizeMouseAtCenter(iconEl, {}, iconEl.ownerGlobal); +}; + +/** + * Change playback rate selector to select given rate. + * + * @param {AnimationInspector} animationInspector + * @param {DOMElement} panel + * #animation-container element. + * @param {Number} rate + */ +const changePlaybackRateSelector = async function ( + animationInspector, + panel, + rate +) { + info(`Click on playback rate selector to select ${rate}`); + const selectEl = panel.querySelector(".playback-rate-selector"); + const optionIndex = [...selectEl.options].findIndex(o => +o.value == rate); + + if (optionIndex == -1) { + ok( + false, + `Could not find an option for rate ${rate} in the rate selector. ` + + `Values are: ${[...selectEl.options].map(o => o.value)}` + ); + return; + } + + selectEl.focus(); + + const win = selectEl.ownerGlobal; + while (selectEl.selectedIndex != optionIndex) { + const key = selectEl.selectedIndex > optionIndex ? "LEFT" : "RIGHT"; + EventUtils.sendKey(key, win); + } +}; + +/** + * Click on given summary graph element. + * + * @param {AnimationInspector} animationInspector + * @param {DOMElement} panel + * #animation-container element. + * @param {Element} summaryGraphEl + */ +const clickOnSummaryGraph = function ( + animationInspector, + panel, + summaryGraphEl +) { + // Disable pointer-events of the scrubber in order to avoid to click accidently. + const scrubberEl = panel.querySelector(".current-time-scrubber"); + scrubberEl.style.pointerEvents = "none"; + // Scroll to show the timeBlock since the element may be out of displayed area. + summaryGraphEl.scrollIntoView(false); + EventUtils.synthesizeMouseAtCenter( + summaryGraphEl, + {}, + summaryGraphEl.ownerGlobal + ); + // Restore the scrubber style. + scrubberEl.style.pointerEvents = "unset"; +}; + +/** + * Click on the target node for the given AnimationTargetComponent index. + * + * @param {AnimationInspector} animationInspector. + * @param {DOMElement} panel + * #animation-container element. + * @param {Number} index + * The index of the AnimationTargetComponent to click on. + */ +const clickOnTargetNode = async function (animationInspector, panel, index) { + const { inspector } = animationInspector; + const { waitForHighlighterTypeShown } = getHighlighterTestHelpers(inspector); + info(`Click on a target node in animation target component[${index}]`); + + const animationItemEl = await findAnimationItemByIndex(panel, index); + const targetEl = animationItemEl.querySelector( + ".animation-target .objectBox" + ); + const onHighlight = waitForHighlighterTypeShown( + inspector.highlighters.TYPES.BOXMODEL + ); + EventUtils.synthesizeMouseAtCenter(targetEl, {}, targetEl.ownerGlobal); + await onHighlight; +}; + +/** + * Drag on the scrubber to update the animation current time. + * + * @param {DOMElement} panel + * #animation-container element. + * @param {Number} mouseMovePixel + * Dispatch mousemove event with mouseMovePosition after mousedown. + * @param {Number} mouseYPixel + * Y of mouse in pixel. + */ +const dragOnCurrentTimeScrubber = async function ( + animationInspector, + panel, + mouseMovePixel, + mouseYPixel +) { + const controllerEl = panel.querySelector(".current-time-scrubber"); + info(`Drag scrubber to X ${mouseMovePixel}`); + EventUtils.synthesizeMouse( + controllerEl, + 0, + mouseYPixel, + { type: "mousedown" }, + controllerEl.ownerGlobal + ); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + + const animation = animationInspector.state.animations[0]; + let currentTime = animation.state.currentTime; + EventUtils.synthesizeMouse( + controllerEl, + mouseMovePixel, + mouseYPixel, + { type: "mousemove" }, + controllerEl.ownerGlobal + ); + await waitUntil(() => animation.state.currentTime !== currentTime); + + currentTime = animation.state.currentTime; + EventUtils.synthesizeMouse( + controllerEl, + mouseMovePixel, + mouseYPixel, + { type: "mouseup" }, + controllerEl.ownerGlobal + ); + await waitUntil(() => animation.state.currentTime !== currentTime); +}; + +/** + * Drag on the scrubber controller pane to update the animation current time. + * + * @param {DOMElement} panel + * #animation-container element. + * @param {Number} mouseDownPosition + * rate on scrubber controller pane. + * This method calculates + * `mouseDownPosition * offsetWidth + offsetLeft of scrubber controller pane` + * as the clientX of MouseEvent. + * @param {Number} mouseMovePosition + * Dispatch mousemove event with mouseMovePosition after mousedown. + * Calculation for clinetX is same to above. + */ +const dragOnCurrentTimeScrubberController = async function ( + animationInspector, + panel, + mouseDownPosition, + mouseMovePosition +) { + const controllerEl = panel.querySelector(".current-time-scrubber-area"); + const bounds = controllerEl.getBoundingClientRect(); + const mousedonwX = bounds.width * mouseDownPosition; + const mousemoveX = bounds.width * mouseMovePosition; + + info(`Drag on scrubber controller from ${mousedonwX} to ${mousemoveX}`); + EventUtils.synthesizeMouse( + controllerEl, + mousedonwX, + 0, + { type: "mousedown" }, + controllerEl.ownerGlobal + ); + await waitUntilAnimationsPlayState(animationInspector, "paused"); + + const animation = animationInspector.state.animations[0]; + let currentTime = animation.state.currentTime; + EventUtils.synthesizeMouse( + controllerEl, + mousemoveX, + 0, + { type: "mousemove" }, + controllerEl.ownerGlobal + ); + await waitUntil(() => animation.state.currentTime !== currentTime); + + currentTime = animation.state.currentTime; + EventUtils.synthesizeMouse( + controllerEl, + mousemoveX, + 0, + { type: "mouseup" }, + controllerEl.ownerGlobal + ); + await waitUntil(() => animation.state.currentTime !== currentTime); +}; + +/** + * Get current animation duration and rate of + * clickOrDragOnCurrentTimeScrubberController in given pixels. + * + * @param {AnimationInspector} animationInspector + * @param {DOMElement} panel + * #animation-container element. + * @param {Number} pixels + * @return {Object} + * { + * duration, + * rate, + * } + */ +const getDurationAndRate = function (animationInspector, panel, pixels) { + const controllerEl = panel.querySelector(".current-time-scrubber-area"); + const bounds = controllerEl.getBoundingClientRect(); + const duration = + (animationInspector.state.timeScale.getDuration() / bounds.width) * pixels; + const rate = (1 / bounds.width) * pixels; + return { duration, rate }; +}; + +/** + * Mouse over the target node for the given AnimationTargetComponent index. + * + * @param {AnimationInspector} animationInspector. + * @param {DOMElement} panel + * #animation-container element. + * @param {Number} index + * The index of the AnimationTargetComponent to click on. + */ +const mouseOverOnTargetNode = function (animationInspector, panel, index) { + info(`Mouse over on a target node in animation target component[${index}]`); + const el = panel.querySelectorAll(".animation-target .objectBox")[index]; + el.scrollIntoView(false); + EventUtils.synthesizeMouse(el, 10, 5, { type: "mouseover" }, el.ownerGlobal); +}; + +/** + * Mouse out of the target node for the given AnimationTargetComponent index. + * + * @param {AnimationInspector} animationInspector. + * @param {DOMElement} panel + * #animation-container element. + * @param {Number} index + * The index of the AnimationTargetComponent to click on. + */ +const mouseOutOnTargetNode = function (animationInspector, panel, index) { + info(`Mouse out on a target node in animation target component[${index}]`); + const el = panel.querySelectorAll(".animation-target .objectBox")[index]; + el.scrollIntoView(false); + EventUtils.synthesizeMouse(el, -1, -1, { type: "mouseout" }, el.ownerGlobal); +}; + +/** + * Select animation inspector in sidebar and toolbar. + * + * @param {InspectorPanel} inspector + */ +const selectAnimationInspector = async function (inspector) { + await inspector.toolbox.selectTool("inspector"); + const onDispatched = waitForDispatch(inspector.store, "UPDATE_ANIMATIONS"); + inspector.sidebar.select("animationinspector"); + await onDispatched; +}; + +/** + * Send keyboard event of space to given panel. + * + * @param {AnimationInspector} animationInspector + * @param {DOMElement} target element. + */ +const sendSpaceKeyEvent = function (animationInspector, element) { + element.focus(); + EventUtils.sendKey("SPACE", element.ownerGlobal); +}; + +/** + * Set a node class attribute to the given selector. + * + * @param {AnimationInspector} animationInspector + * @param {String} selector + * @param {String} cls + * e.g. ".ball.still" + */ +const setClassAttribute = async function (animationInspector, selector, cls) { + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [cls, selector], + (attributeValue, selectorChild) => { + const node = content.document.querySelector(selectorChild); + if (!node) { + return; + } + + node.setAttribute("class", attributeValue); + } + ); +}; + +/** + * Set a new style properties to the node for the given selector. + * + * @param {AnimationInspector} animationInspector + * @param {String} selector + * @param {Object} properties + * e.g. { + * animationDuration: "1000ms", + * animationTimingFunction: "linear", + * } + */ +const setEffectTimingAndPlayback = async function ( + animationInspector, + selector, + effectTiming, + playbackRate +) { + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [selector, playbackRate, effectTiming], + (selectorChild, playbackRateChild, effectTimingChild) => { + let selectedAnimation = null; + + for (const animation of content.document.getAnimations()) { + if (animation.effect.target.matches(selectorChild)) { + selectedAnimation = animation; + break; + } + } + + if (!selectedAnimation) { + return; + } + + selectedAnimation.playbackRate = playbackRateChild; + selectedAnimation.effect.updateTiming(effectTimingChild); + } + ); +}; + +/** + * Set the sidebar width by given parameter. + * + * @param {String} width + * Change sidebar width by given parameter. + * @param {InspectorPanel} inspector + * The instance of InspectorPanel currently loaded in the toolbox + * @return {Promise} Resolves when the sidebar size changed. + */ +const setSidebarWidth = async function (width, inspector) { + const onUpdated = inspector.toolbox.once("inspector-sidebar-resized"); + inspector.splitBox.setState({ width }); + await onUpdated; +}; + +/** + * Set a new style property declaration to the node for the given selector. + * + * @param {AnimationInspector} animationInspector + * @param {String} selector + * @param {String} propertyName + * e.g. "animationDuration" + * @param {String} propertyValue + * e.g. "5.5s" + */ +const setStyle = async function ( + animationInspector, + selector, + propertyName, + propertyValue +) { + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [selector, propertyName, propertyValue], + (selectorChild, propertyNameChild, propertyValueChild) => { + const node = content.document.querySelector(selectorChild); + if (!node) { + return; + } + + node.style[propertyNameChild] = propertyValueChild; + } + ); +}; + +/** + * Set a new style properties to the node for the given selector. + * + * @param {AnimationInspector} animationInspector + * @param {String} selector + * @param {Object} properties + * e.g. { + * animationDuration: "1000ms", + * animationTimingFunction: "linear", + * } + */ +const setStyles = async function (animationInspector, selector, properties) { + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [properties, selector], + (propertiesChild, selectorChild) => { + const node = content.document.querySelector(selectorChild); + if (!node) { + return; + } + + for (const propertyName in propertiesChild) { + const propertyValue = propertiesChild[propertyName]; + node.style[propertyName] = propertyValue; + } + } + ); +}; + +/** + * Wait until curren time of animations will be changed to give currrent time. + * + * @param {AnimationInspector} animationInspector + * @param {Number} currentTime + */ +const waitUntilCurrentTimeChangedAt = async function ( + animationInspector, + currentTime +) { + info(`Wait until current time will be change to ${currentTime}`); + await waitUntil(() => + animationInspector.state.animations.every( + a => a.state.currentTime === currentTime + ) + ); +}; + +/** + * Wait until animations' play state will be changed to given state. + * + * @param {Array} animationInspector + * @param {String} state + */ +const waitUntilAnimationsPlayState = async function ( + animationInspector, + state +) { + info(`Wait until play state will be change to ${state}`); + await waitUntil(() => + animationInspector.state.animations.every(a => a.state.playState === state) + ); +}; + +/** + * Return count of graph that animation inspector is displaying. + * + * @param {AnimationInspector} animationInspector + * @param {DOMElement} panel + * @return {Number} count + */ +const getDisplayedGraphCount = (animationInspector, panel) => { + const animationLength = animationInspector.state.animations.length; + if (animationLength === 0) { + return 0; + } + + const inspectionPanelEl = panel.querySelector(".progress-inspection-panel"); + const itemEl = panel.querySelector(".animation-item"); + const listEl = panel.querySelector(".animation-list"); + const itemHeight = itemEl.offsetHeight; + // This calculation should be same as AnimationListContainer.updateDisplayableRange. + const count = Math.floor(listEl.offsetHeight / itemHeight) + 1; + const index = Math.floor(inspectionPanelEl.scrollTop / itemHeight); + + return animationLength > index + count ? count : animationLength - index; +}; + +/** + * Check whether the animations are pausing. + * + * @param {AnimationInspector} animationInspector + */ +function assertAnimationsPausing(animationInspector) { + assertAnimationsPausingOrRunning(animationInspector, true); +} + +/** + * Check whether the animations are pausing/running. + * + * @param {AnimationInspector} animationInspector + * @param {boolean} shouldPause + */ +function assertAnimationsPausingOrRunning(animationInspector, shouldPause) { + const hasRunningAnimation = animationInspector.state.animations.some( + ({ state }) => state.playState === "running" + ); + + if (shouldPause) { + is(hasRunningAnimation, false, "All animations should be paused"); + } else { + is(hasRunningAnimation, true, "Animations should be running at least one"); + } +} + +/** + * Check whether the animations are running. + * + * @param {AnimationInspector} animationInspector + */ +function assertAnimationsRunning(animationInspector) { + assertAnimationsPausingOrRunning(animationInspector, false); +} + +/** + * Check the <stop> element in the given linearGradientEl for the correct offset + * and color attributes. + * + * @param {Element} linearGradientEl + <linearGradient> element which has <stop> element. + * @param {Number} offset + * float which represents the "offset" attribute of <stop>. + * @param {String} expectedColor + * e.g. rgb(0, 0, 255) + */ +function assertLinearGradient(linearGradientEl, offset, expectedColor) { + const stopEl = findStopElement(linearGradientEl, offset); + ok(stopEl, `stop element at offset ${offset} should exist`); + is( + stopEl.getAttribute("stop-color"), + expectedColor, + `stop-color of stop element at offset ${offset} should be ${expectedColor}` + ); +} + +/** + * SummaryGraph is constructed by <path> element. + * This function checks the vertex of path segments. + * + * @param {Element} pathEl + * <path> element. + * @param {boolean} hasClosePath + * Set true if the path shoud be closing. + * @param {Object} expectedValues + * JSON object format. We can test the vertex and color. + * e.g. + * [ + * { x: 0, y: 0 }, + * { x: 0, y: 1 }, + * ] + */ +function assertPathSegments(pathEl, hasClosePath, expectedValues) { + ok( + isExpectedPath(pathEl, hasClosePath, expectedValues), + "All of path segments are correct" + ); +} + +function isExpectedPath(pathEl, hasClosePath, expectedValues) { + const pathSegList = pathEl.pathSegList; + if (!pathSegList) { + return false; + } + + if ( + !expectedValues.every(value => + isPassingThrough(pathSegList, value.x, value.y) + ) + ) { + return false; + } + + if (hasClosePath) { + const closePathSeg = pathSegList.getItem(pathSegList.numberOfItems - 1); + if (closePathSeg.pathSegType !== closePathSeg.PATHSEG_CLOSEPATH) { + return false; + } + } + + return true; +} + +/** + * Check whether the given vertex is passing throug on the path. + * + * @param {pathSegList} pathSegList - pathSegList of <path> element. + * @param {float} x - x of vertex. + * @param {float} y - y of vertex. + * @return {boolean} true: passing through, false: no on the path. + */ +function isPassingThrough(pathSegList, x, y) { + let previousPathSeg = pathSegList.getItem(0); + for (let i = 0; i < pathSegList.numberOfItems; i++) { + const pathSeg = pathSegList.getItem(i); + if (pathSeg.x === undefined) { + continue; + } + const currentX = parseFloat(pathSeg.x.toFixed(3)); + const currentY = parseFloat(pathSeg.y.toFixed(3)); + if (currentX === x && currentY === y) { + return true; + } + const previousX = parseFloat(previousPathSeg.x.toFixed(3)); + const previousY = parseFloat(previousPathSeg.y.toFixed(3)); + if ( + previousX <= x && + x <= currentX && + Math.min(previousY, currentY) <= y && + y <= Math.max(previousY, currentY) + ) { + return true; + } + previousPathSeg = pathSeg; + } + return false; +} + +/** + * Return animation item element by the index. + * + * @param {DOMElement} panel + * #animation-container element. + * @param {Number} index + * @return {DOMElement} + * Animation item element. + */ +async function findAnimationItemByIndex(panel, index) { + const itemEls = [...panel.querySelectorAll(".animation-item")]; + const itemEl = itemEls[index]; + itemEl.scrollIntoView(false); + + await waitUntil( + () => + itemEl.querySelector(".animation-target .attrName") && + itemEl.querySelector(".animation-computed-timing-path") + ); + + return itemEl; +} + +/** + * Return animation item element by target node selector. + * This function compares betweem animation-target textContent and given selector. + * Then returns matched first item. + * + * @param {DOMElement} panel + * #animation-container element. + * @param {String} selector + * Selector of tested element. + * @return {DOMElement} + * Animation item element. + */ +async function findAnimationItemByTargetSelector(panel, selector) { + for (const itemEl of panel.querySelectorAll(".animation-item")) { + itemEl.scrollIntoView(false); + + await waitUntil( + () => + itemEl.querySelector(".animation-target .attrName") && + itemEl.querySelector(".animation-computed-timing-path") + ); + + const attrNameEl = itemEl.querySelector(".animation-target .attrName"); + const regexp = new RegExp(`\\${selector}(\\.|$)`, "gi"); + if (regexp.exec(attrNameEl.textContent)) { + return itemEl; + } + } + + return null; +} + +/** + * Find the <stop> element which has the given offset in the given linearGradientEl. + * + * @param {Element} linearGradientEl + * <linearGradient> element which has <stop> element. + * @param {Number} offset + * Float which represents the "offset" attribute of <stop>. + * @return {Element} + * If can't find suitable element, returns null. + */ +function findStopElement(linearGradientEl, offset) { + for (const stopEl of linearGradientEl.querySelectorAll("stop")) { + if (offset <= parseFloat(stopEl.getAttribute("offset"))) { + return stopEl; + } + } + + return null; +} + +/** + * Do test for keyframes-graph_computed-value-path-1/2. + * + * @param {Array} testData + */ +async function testKeyframesGraphComputedValuePath(testData) { + await addTab(URL_ROOT + "doc_multi_keyframes.html"); + await removeAnimatedElementsExcept(testData.map(t => `.${t.targetClass}`)); + const { animationInspector, panel } = await openAnimationInspector(); + + for (const { properties, targetClass } of testData) { + info(`Checking keyframes graph for ${targetClass}`); + const onDetailRendered = animationInspector.once( + "animation-keyframes-rendered" + ); + await clickOnAnimationByTargetSelector( + animationInspector, + panel, + `.${targetClass}` + ); + await onDetailRendered; + + for (const property of properties) { + const { + name, + computedValuePathClass, + expectedPathSegments, + expectedStopColors, + } = property; + + const testTarget = `${name} in ${targetClass}`; + info(`Checking keyframes graph for ${testTarget}`); + info(`Checking keyframes graph path existence for ${testTarget}`); + const keyframesGraphPathEl = panel.querySelector(`.${name}`); + ok( + keyframesGraphPathEl, + `The keyframes graph path element of ${testTarget} should be existence` + ); + + info(`Checking computed value path existence for ${testTarget}`); + const computedValuePathEl = keyframesGraphPathEl.querySelector( + `.${computedValuePathClass}` + ); + ok( + computedValuePathEl, + `The computed value path element of ${testTarget} should be existence` + ); + + info(`Checking path segments for ${testTarget}`); + const pathEl = computedValuePathEl.querySelector("path"); + ok(pathEl, `The <path> element of ${testTarget} should be existence`); + assertPathSegments(pathEl, true, expectedPathSegments); + + if (!expectedStopColors) { + continue; + } + + info(`Checking linearGradient for ${testTarget}`); + const linearGradientEl = + computedValuePathEl.querySelector("linearGradient"); + ok( + linearGradientEl, + `The <linearGradientEl> element of ${testTarget} should be existence` + ); + + for (const expectedStopColor of expectedStopColors) { + const { offset, color } = expectedStopColor; + assertLinearGradient(linearGradientEl, offset, color); + } + } + } +} + +/** + * Check the adjusted current time and created time from specified two animations. + * + * @param {AnimationPlayerFront.state} animation1 + * @param {AnimationPlayerFront.state} animation2 + */ +function checkAdjustingTheTime(animation1, animation2) { + const adjustedCurrentTimeDiff = + animation2.currentTime / animation2.playbackRate - + animation1.currentTime / animation1.playbackRate; + const createdTimeDiff = animation1.createdTime - animation2.createdTime; + ok( + Math.abs(adjustedCurrentTimeDiff - createdTimeDiff) < 0.1, + "Adjusted time is correct" + ); +} diff --git a/devtools/client/inspector/animation/test/keyframes-graph_keyframe-marker_head.js b/devtools/client/inspector/animation/test/keyframes-graph_keyframe-marker_head.js new file mode 100644 index 0000000000..97c7040553 --- /dev/null +++ b/devtools/client/inspector/animation/test/keyframes-graph_keyframe-marker_head.js @@ -0,0 +1,237 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +// Test for following keyframe marker. +// * element existence +// * title +// * and marginInlineStart style + +const KEYFRAMES_TEST_DATA = [ + { + targetClass: "multi-types", + properties: [ + { + name: "background-color", + expectedValues: [ + { + title: "rgb(255, 0, 0)", + marginInlineStart: "0%", + }, + { + title: "rgb(0, 255, 0)", + marginInlineStart: "100%", + }, + ], + }, + { + name: "background-repeat", + expectedValues: [ + { + title: "space round", + marginInlineStart: "0%", + }, + { + title: "round space", + marginInlineStart: "100%", + }, + ], + }, + { + name: "font-size", + expectedValues: [ + { + title: "10px", + marginInlineStart: "0%", + }, + { + title: "20px", + marginInlineStart: "100%", + }, + ], + }, + { + name: "margin-left", + expectedValues: [ + { + title: "0px", + marginInlineStart: "0%", + }, + { + title: "100px", + marginInlineStart: "100%", + }, + ], + }, + { + name: "opacity", + expectedValues: [ + { + title: "0", + marginInlineStart: "0%", + }, + { + title: "1", + marginInlineStart: "100%", + }, + ], + }, + { + name: "text-align", + expectedValues: [ + { + title: "right", + marginInlineStart: "0%", + }, + { + title: "center", + marginInlineStart: "100%", + }, + ], + }, + { + name: "transform", + expectedValues: [ + { + title: "translate(0px)", + marginInlineStart: "0%", + }, + { + title: "translate(100px)", + marginInlineStart: "100%", + }, + ], + }, + ], + }, + { + targetClass: "narrow-offsets", + properties: [ + { + name: "opacity", + expectedValues: [ + { + title: "0", + marginInlineStart: "0%", + }, + { + title: "1", + marginInlineStart: "10%", + }, + { + title: "0", + marginInlineStart: "13%", + }, + { + title: "1", + marginInlineStart: "100%", + }, + ], + }, + ], + }, + { + targetClass: "same-color", + properties: [ + { + name: "background-color", + expectedValues: [ + { + title: "rgb(0, 255, 0)", + marginInlineStart: "0%", + }, + { + title: "rgb(0, 255, 0)", + marginInlineStart: "100%", + }, + ], + }, + ], + }, + { + targetClass: "currentcolor", + properties: [ + { + name: "background-color", + expectedValues: [ + { + title: "currentcolor", + marginInlineStart: "0%", + }, + { + title: "rgb(0, 255, 0)", + marginInlineStart: "100%", + }, + ], + }, + ], + }, +]; + +/** + * Do test for keyframes-graph_keyframe-marker-ltf/rtl. + * + * @param {Array} testData + */ +// eslint-disable-next-line no-unused-vars +async function testKeyframesGraphKeyframesMarker() { + await addTab(URL_ROOT + "doc_multi_keyframes.html"); + await removeAnimatedElementsExcept( + KEYFRAMES_TEST_DATA.map(t => `.${t.targetClass}`) + ); + const { animationInspector, panel } = await openAnimationInspector(); + + for (const { properties, targetClass } of KEYFRAMES_TEST_DATA) { + info(`Checking keyframe marker for ${targetClass}`); + const onDetailRendered = animationInspector.once( + "animation-keyframes-rendered" + ); + await clickOnAnimationByTargetSelector( + animationInspector, + panel, + `.${targetClass}` + ); + await onDetailRendered; + + for (const { name, expectedValues } of properties) { + const testTarget = `${name} in ${targetClass}`; + info(`Checking keyframe marker for ${testTarget}`); + info(`Checking keyframe marker existence for ${testTarget}`); + const markerEls = panel.querySelectorAll( + `.${name} .keyframe-marker-item` + ); + is( + markerEls.length, + expectedValues.length, + `Count of keyframe marker elements of ${testTarget} ` + + `should be ${expectedValues.length}` + ); + + for (let i = 0; i < expectedValues.length; i++) { + const hintTarget = `.keyframe-marker-item[${i}] of ${testTarget}`; + + info(`Checking ${hintTarget}`); + const markerEl = markerEls[i]; + const expectedValue = expectedValues[i]; + + info(`Checking title in ${hintTarget}`); + is( + markerEl.getAttribute("title"), + expectedValue.title, + `title in ${hintTarget} should be ${expectedValue.title}` + ); + + info(`Checking marginInlineStart style in ${hintTarget}`); + is( + markerEl.style.marginInlineStart, + expectedValue.marginInlineStart, + `marginInlineStart in ${hintTarget} should be ` + + `${expectedValue.marginInlineStart}` + ); + } + } + } +} diff --git a/devtools/client/inspector/animation/test/summary-graph_computed-timing-path_head.js b/devtools/client/inspector/animation/test/summary-graph_computed-timing-path_head.js new file mode 100644 index 0000000000..8516e96fa3 --- /dev/null +++ b/devtools/client/inspector/animation/test/summary-graph_computed-timing-path_head.js @@ -0,0 +1,103 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +/** + * Test for computed timing path on summary graph using given test data. + * @param {Array} testData + */ +// eslint-disable-next-line no-unused-vars +async function testComputedTimingPath(testData) { + await addTab(URL_ROOT + "doc_multi_timings.html"); + await removeAnimatedElementsExcept(testData.map(t => `.${t.targetClass}`)); + const { panel } = await openAnimationInspector(); + + for (const { + expectedDelayPath, + expectedEndDelayPath, + expectedForwardsPath, + expectedIterationPathList, + isInfinity, + targetClass, + } of testData) { + const animationItemEl = await findAnimationItemByTargetSelector( + panel, + `.${targetClass}` + ); + + info(`Checking computed timing path existance for ${targetClass}`); + const computedTimingPathEl = animationItemEl.querySelector( + ".animation-computed-timing-path" + ); + ok( + computedTimingPathEl, + "The computed timing path element should be in each animation item element" + ); + + info(`Checking delay path for ${targetClass}`); + const delayPathEl = computedTimingPathEl.querySelector( + ".animation-delay-path" + ); + + if (expectedDelayPath) { + ok(delayPathEl, "delay path should be existance"); + assertPathSegments(delayPathEl, true, expectedDelayPath); + } else { + ok(!delayPathEl, "delay path should not be existance"); + } + + info(`Checking iteration path list for ${targetClass}`); + const iterationPathEls = computedTimingPathEl.querySelectorAll( + ".animation-iteration-path" + ); + is( + iterationPathEls.length, + expectedIterationPathList.length, + `Number of iteration path should be ${expectedIterationPathList.length}` + ); + + for (const [j, iterationPathEl] of iterationPathEls.entries()) { + assertPathSegments(iterationPathEl, true, expectedIterationPathList[j]); + + info(`Checking infinity ${targetClass}`); + if (isInfinity && j >= 1) { + ok( + iterationPathEl.classList.contains("infinity"), + "iteration path should have 'infinity' class" + ); + } else { + ok( + !iterationPathEl.classList.contains("infinity"), + "iteration path should not have 'infinity' class" + ); + } + } + + info(`Checking endDelay path for ${targetClass}`); + const endDelayPathEl = computedTimingPathEl.querySelector( + ".animation-enddelay-path" + ); + + if (expectedEndDelayPath) { + ok(endDelayPathEl, "endDelay path should be existance"); + assertPathSegments(endDelayPathEl, true, expectedEndDelayPath); + } else { + ok(!endDelayPathEl, "endDelay path should not be existance"); + } + + info(`Checking forwards fill path for ${targetClass}`); + const forwardsPathEl = computedTimingPathEl.querySelector( + ".animation-fill-forwards-path" + ); + + if (expectedForwardsPath) { + ok(forwardsPathEl, "forwards path should be existance"); + assertPathSegments(forwardsPathEl, true, expectedForwardsPath); + } else { + ok(!forwardsPathEl, "forwards path should not be existance"); + } + } +} diff --git a/devtools/client/inspector/animation/test/summary-graph_delay-sign_head.js b/devtools/client/inspector/animation/test/summary-graph_delay-sign_head.js new file mode 100644 index 0000000000..38332ecfc0 --- /dev/null +++ b/devtools/client/inspector/animation/test/summary-graph_delay-sign_head.js @@ -0,0 +1,105 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +// Test for following DelaySign component works. +// * element existance +// * marginInlineStart position +// * width +// * additinal class + +const TEST_DATA = [ + { + targetClass: "delay-positive", + expectedResult: { + marginInlineStart: "25%", + width: "25%", + }, + }, + { + targetClass: "delay-negative", + expectedResult: { + additionalClass: "negative", + marginInlineStart: "0%", + width: "25%", + }, + }, + { + targetClass: "fill-backwards-with-delay-iterationstart", + expectedResult: { + additionalClass: "fill", + marginInlineStart: "25%", + width: "25%", + }, + }, + { + targetClass: "fill-both", + }, + { + targetClass: "fill-both-width-delay-iterationstart", + expectedResult: { + additionalClass: "fill", + marginInlineStart: "25%", + width: "25%", + }, + }, + { + targetClass: "keyframes-easing-step", + }, +]; + +// eslint-disable-next-line no-unused-vars +async function testSummaryGraphDelaySign() { + await addTab(URL_ROOT + "doc_multi_timings.html"); + await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`)); + const { panel } = await openAnimationInspector(); + + for (const { targetClass, expectedResult } of TEST_DATA) { + const animationItemEl = await findAnimationItemByTargetSelector( + panel, + `.${targetClass}` + ); + + info(`Checking delay sign existance for ${targetClass}`); + const delaySignEl = animationItemEl.querySelector(".animation-delay-sign"); + + if (expectedResult) { + ok( + delaySignEl, + "The delay sign element should be in animation item element" + ); + + is( + delaySignEl.style.marginInlineStart, + expectedResult.marginInlineStart, + `marginInlineStart position should be ${expectedResult.marginInlineStart}` + ); + is( + delaySignEl.style.width, + expectedResult.width, + `Width should be ${expectedResult.width}` + ); + + if (expectedResult.additionalClass) { + ok( + delaySignEl.classList.contains(expectedResult.additionalClass), + `delay sign element should have ${expectedResult.additionalClass} class` + ); + } else { + ok( + !delaySignEl.classList.contains(expectedResult.additionalClass), + "delay sign element should not have " + + `${expectedResult.additionalClass} class` + ); + } + } else { + ok( + !delaySignEl, + "The delay sign element should not be in animation item element" + ); + } + } +} diff --git a/devtools/client/inspector/animation/test/summary-graph_end-delay-sign_head.js b/devtools/client/inspector/animation/test/summary-graph_end-delay-sign_head.js new file mode 100644 index 0000000000..945cced3a4 --- /dev/null +++ b/devtools/client/inspector/animation/test/summary-graph_end-delay-sign_head.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +// Test for following EndDelaySign component works. +// * element existance +// * marginInlineStart position +// * width +// * additinal class + +const TEST_DATA = [ + { + targetClass: "enddelay-positive", + expectedResult: { + marginInlineStart: "75%", + width: "25%", + }, + }, + { + targetClass: "enddelay-negative", + expectedResult: { + additionalClass: "negative", + marginInlineStart: "50%", + width: "25%", + }, + }, + { + targetClass: "enddelay-with-fill-forwards", + expectedResult: { + additionalClass: "fill", + marginInlineStart: "75%", + width: "25%", + }, + }, + { + targetClass: "enddelay-with-iterations-infinity", + }, + { + targetClass: "delay-negative", + }, +]; + +// eslint-disable-next-line no-unused-vars +async function testSummaryGraphEndDelaySign() { + await addTab(URL_ROOT + "doc_multi_timings.html"); + await removeAnimatedElementsExcept(TEST_DATA.map(t => `.${t.targetClass}`)); + const { panel } = await openAnimationInspector(); + + for (const { targetClass, expectedResult } of TEST_DATA) { + const animationItemEl = await findAnimationItemByTargetSelector( + panel, + `.${targetClass}` + ); + + info(`Checking endDelay sign existance for ${targetClass}`); + const endDelaySignEl = animationItemEl.querySelector( + ".animation-end-delay-sign" + ); + + if (expectedResult) { + ok( + endDelaySignEl, + "The endDelay sign element should be in animation item element" + ); + + is( + endDelaySignEl.style.marginInlineStart, + expectedResult.marginInlineStart, + `marginInlineStart position should be ${expectedResult.marginInlineStart}` + ); + is( + endDelaySignEl.style.width, + expectedResult.width, + `Width should be ${expectedResult.width}` + ); + + if (expectedResult.additionalClass) { + ok( + endDelaySignEl.classList.contains(expectedResult.additionalClass), + `endDelay sign element should have ${expectedResult.additionalClass} class` + ); + } else { + ok( + !endDelaySignEl.classList.contains(expectedResult.additionalClass), + "endDelay sign element should not have " + + `${expectedResult.additionalClass} class` + ); + } + } else { + ok( + !endDelaySignEl, + "The endDelay sign element should not be in animation item element" + ); + } + } +} diff --git a/devtools/client/inspector/animation/utils/graph-helper.js b/devtools/client/inspector/animation/utils/graph-helper.js new file mode 100644 index 0000000000..cca2713254 --- /dev/null +++ b/devtools/client/inspector/animation/utils/graph-helper.js @@ -0,0 +1,332 @@ +/* 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"; + +// BOUND_EXCLUDING_TIME should be less than 1ms and is used to exclude start +// and end bounds when dividing duration in createPathSegments. +const BOUND_EXCLUDING_TIME = 0.001; +// We define default graph height since if the height of viewport in SVG is +// too small (e.g. 1), vector-effect may not be able to calculate correctly. +const DEFAULT_GRAPH_HEIGHT = 100; +// Default animation duration for keyframes graph. +const DEFAULT_KEYFRAMES_GRAPH_DURATION = 1000; +// DEFAULT_MIN_PROGRESS_THRESHOLD shoud be between more than 0 to 1. +const DEFAULT_MIN_PROGRESS_THRESHOLD = 0.1; +// In the createPathSegments function, an animation duration is divided by +// DEFAULT_DURATION_RESOLUTION in order to draw the way the animation progresses. +// But depending on the timing-function, we may be not able to make the graph +// smoothly progress if this resolution is not high enough. +// So, if the difference of animation progress between 2 divisions is more than +// DEFAULT_MIN_PROGRESS_THRESHOLD * DEFAULT_GRAPH_HEIGHT, then createPathSegments +// re-divides by DEFAULT_DURATION_RESOLUTION. +// DEFAULT_DURATION_RESOLUTION shoud be integer and more than 2. +const DEFAULT_DURATION_RESOLUTION = 4; +// Stroke width for easing hint. +const DEFAULT_EASING_HINT_STROKE_WIDTH = 5; + +/** + * The helper class for creating summary graph. + */ +class SummaryGraphHelper { + /** + * Constructor. + * + * @param {Object} state + * State of animation. + * @param {Array} keyframes + * Array of keyframe. + * @param {Number} totalDuration + * Total displayable duration. + * @param {Number} minSegmentDuration + * Minimum segment duration. + * @param {Function} getValueFunc + * Which returns graph value of given time. + * The function should return a number value between 0 - 1. + * e.g. time => { return 1.0 }; + * @param {Function} toPathStringFunc + * Which returns a path string for 'd' attribute for <path> from given segments. + */ + constructor( + state, + keyframes, + totalDuration, + minSegmentDuration, + getValueFunc, + toPathStringFunc + ) { + this.totalDuration = totalDuration; + this.minSegmentDuration = minSegmentDuration; + this.minProgressThreshold = + getPreferredProgressThreshold(state, keyframes) * DEFAULT_GRAPH_HEIGHT; + this.durationResolution = getPreferredDurationResolution(keyframes); + this.getValue = getValueFunc; + this.toPathString = toPathStringFunc; + + this.getSegment = this.getSegment.bind(this); + } + + /** + * Create the path segments from given parameters. + * + * @param {Number} startTime + * Starting time of animation. + * @param {Number} endTime + * Ending time of animation. + * @return {Array} + * Array of path segment. + * e.g.[{x: {Number} time, y: {Number} progress}, ...] + */ + createPathSegments(startTime, endTime) { + return createPathSegments( + startTime, + endTime, + this.minSegmentDuration, + this.minProgressThreshold, + this.durationResolution, + this.getSegment + ); + } + + /** + * Return a coordinate as a graph segment at given time. + * + * @param {Number} time + * @return {Object} + * { x: Number, y: Number } + */ + getSegment(time) { + const value = this.getValue(time); + return { x: time, y: value * DEFAULT_GRAPH_HEIGHT }; + } +} + +/** + * Create the path segments from given parameters. + * + * @param {Number} startTime + * Starting time of animation. + * @param {Number} endTime + * Ending time of animation. + * @param {Number} minSegmentDuration + * Minimum segment duration. + * @param {Number} minProgressThreshold + * Minimum progress threshold. + * @param {Number} resolution + * Duration resolution for first time. + * @param {Function} getSegment + * A function that calculate the graph segment. + * @return {Array} + * Array of path segment. + * e.g.[{x: {Number} time, y: {Number} progress}, ...] + */ +function createPathSegments( + startTime, + endTime, + minSegmentDuration, + minProgressThreshold, + resolution, + getSegment +) { + // If the duration is too short, early return. + if (endTime - startTime < minSegmentDuration) { + return [getSegment(startTime), getSegment(endTime)]; + } + + // Otherwise, start creating segments. + let pathSegments = []; + + // Append the segment for the startTime position. + const startTimeSegment = getSegment(startTime); + pathSegments.push(startTimeSegment); + let previousSegment = startTimeSegment; + + // Split the duration in equal intervals, and iterate over them. + // See the definition of DEFAULT_DURATION_RESOLUTION for more information about this. + const interval = (endTime - startTime) / resolution; + for (let index = 1; index <= resolution; index++) { + // Create a segment for this interval. + const currentSegment = getSegment(startTime + index * interval); + + // If the distance between the Y coordinate (the animation's progress) of + // the previous segment and the Y coordinate of the current segment is too + // large, then recurse with a smaller duration to get more details + // in the graph. + if (Math.abs(currentSegment.y - previousSegment.y) > minProgressThreshold) { + // Divide the current interval (excluding start and end bounds + // by adding/subtracting BOUND_EXCLUDING_TIME). + const nextStartTime = previousSegment.x + BOUND_EXCLUDING_TIME; + const nextEndTime = currentSegment.x - BOUND_EXCLUDING_TIME; + const segments = createPathSegments( + nextStartTime, + nextEndTime, + minSegmentDuration, + minProgressThreshold, + DEFAULT_DURATION_RESOLUTION, + getSegment + ); + pathSegments = pathSegments.concat(segments); + } + + pathSegments.push(currentSegment); + previousSegment = currentSegment; + } + + return pathSegments; +} + +/** + * Create a function which is used as parameter (toPathStringFunc) in constructor + * of SummaryGraphHelper. + * + * @param {Number} endTime + * end time of animation + * e.g. 200 + * @param {Number} playbackRate + * playback rate of animation + * e.g. -1 + * @return {Function} + */ +function createSummaryGraphPathStringFunction(endTime, playbackRate) { + return segments => { + segments = mapSegmentsToPlaybackRate(segments, endTime, playbackRate); + const firstSegment = segments[0]; + let pathString = `M${firstSegment.x},0 `; + pathString += toPathString(segments); + const lastSegment = segments[segments.length - 1]; + pathString += `L${lastSegment.x},0 Z`; + return pathString; + }; +} + +/** + * Return preferred duration resolution. + * This corresponds to narrow interval keyframe offset. + * + * @param {Array} keyframes + * Array of keyframe. + * @return {Number} + * Preferred duration resolution. + */ +function getPreferredDurationResolution(keyframes) { + if (!keyframes) { + return DEFAULT_DURATION_RESOLUTION; + } + + let durationResolution = DEFAULT_DURATION_RESOLUTION; + let previousOffset = 0; + for (const keyframe of keyframes) { + if (previousOffset && previousOffset != keyframe.offset) { + const interval = keyframe.offset - previousOffset; + durationResolution = Math.max( + durationResolution, + Math.ceil(1 / interval) + ); + } + previousOffset = keyframe.offset; + } + + return durationResolution; +} + +/** + * Return preferred progress threshold to render summary graph. + * + * @param {Object} state + * State of animation. + * @param {Array} keyframes + * Array of keyframe. + * @return {float} + * Preferred threshold. + */ +function getPreferredProgressThreshold(state, keyframes) { + const steps = getStepsCount(state.easing); + const threshold = Math.min(DEFAULT_MIN_PROGRESS_THRESHOLD, 1 / (steps + 1)); + + if (!keyframes) { + return threshold; + } + + return Math.min( + threshold, + getPreferredProgressThresholdByKeyframes(keyframes) + ); +} + +/** + * Return preferred progress threshold by keyframes. + * + * @param {Array} keyframes + * Array of keyframe. + * @return {float} + * Preferred threshold. + */ +function getPreferredProgressThresholdByKeyframes(keyframes) { + let threshold = DEFAULT_MIN_PROGRESS_THRESHOLD; + + for (let i = 0; i < keyframes.length - 1; i++) { + const keyframe = keyframes[i]; + + if (!keyframe.easing) { + continue; + } + + const steps = getStepsCount(keyframe.easing); + + if (steps) { + const nextKeyframe = keyframes[i + 1]; + threshold = Math.min( + threshold, + (1 / (steps + 1)) * (nextKeyframe.offset - keyframe.offset) + ); + } + } + + return threshold; +} + +function getStepsCount(easing) { + const stepsFunction = easing.match(/(steps)\((\d+)/); + return stepsFunction ? parseInt(stepsFunction[2], 10) : 0; +} + +function mapSegmentsToPlaybackRate(segments, endTime, playbackRate) { + if (playbackRate > 0) { + return segments; + } + + return segments.map(segment => { + segment.x = endTime - segment.x; + return segment; + }); +} + +/** + * Return path string for 'd' attribute for <path> from given segments. + * + * @param {Array} segments + * e.g. [{ x: 100, y: 0 }, { x: 200, y: 1 }] + * @return {String} + * Path string. + * e.g. "L100,0 L200,1" + */ +function toPathString(segments) { + let pathString = ""; + segments.forEach(segment => { + pathString += `L${segment.x},${segment.y} `; + }); + return pathString; +} + +exports.createPathSegments = createPathSegments; +exports.createSummaryGraphPathStringFunction = + createSummaryGraphPathStringFunction; +exports.DEFAULT_DURATION_RESOLUTION = DEFAULT_DURATION_RESOLUTION; +exports.DEFAULT_EASING_HINT_STROKE_WIDTH = DEFAULT_EASING_HINT_STROKE_WIDTH; +exports.DEFAULT_GRAPH_HEIGHT = DEFAULT_GRAPH_HEIGHT; +exports.DEFAULT_KEYFRAMES_GRAPH_DURATION = DEFAULT_KEYFRAMES_GRAPH_DURATION; +exports.getPreferredProgressThresholdByKeyframes = + getPreferredProgressThresholdByKeyframes; +exports.SummaryGraphHelper = SummaryGraphHelper; +exports.toPathString = toPathString; diff --git a/devtools/client/inspector/animation/utils/l10n.js b/devtools/client/inspector/animation/utils/l10n.js new file mode 100644 index 0000000000..6fffa98b65 --- /dev/null +++ b/devtools/client/inspector/animation/utils/l10n.js @@ -0,0 +1,46 @@ +/* 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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/animationinspector.properties" +); +const INSPECTOR_L10N = new LocalizationHelper( + "devtools/client/locales/inspector.properties" +); + +/** + * Get a formatted title for this animation. This will be either: + * "%S", "%S : CSS Transition", "%S : CSS Animation", + * "%S : Script Animation", or "Script Animation", depending + * if the server provides the type, what type it is and if the animation + * has a name. + * + * @param {Object} state + */ +function getFormattedTitle(state) { + // Older servers don't send a type, and only know about + // CSSAnimations and CSSTransitions, so it's safe to use + // just the name. + if (!state.type) { + return state.name; + } + + // Script-generated animations may not have a name. + if (state.type === "scriptanimation" && !state.name) { + return L10N.getStr("timeline.scriptanimation.unnamedLabel"); + } + + return L10N.getFormatStr(`timeline.${state.type}.nameLabel`, state.name); +} + +module.exports = { + getFormatStr: (...args) => L10N.getFormatStr(...args), + getFormattedTitle, + getInspectorStr: (...args) => INSPECTOR_L10N.getStr(...args), + getStr: (...args) => L10N.getStr(...args), + numberWithDecimals: (...args) => L10N.numberWithDecimals(...args), +}; diff --git a/devtools/client/inspector/animation/utils/moz.build b/devtools/client/inspector/animation/utils/moz.build new file mode 100644 index 0000000000..ae73627a29 --- /dev/null +++ b/devtools/client/inspector/animation/utils/moz.build @@ -0,0 +1,10 @@ +# 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/. + +DevToolsModules( + "graph-helper.js", + "l10n.js", + "timescale.js", + "utils.js", +) diff --git a/devtools/client/inspector/animation/utils/timescale.js b/devtools/client/inspector/animation/utils/timescale.js new file mode 100644 index 0000000000..a831f1267e --- /dev/null +++ b/devtools/client/inspector/animation/utils/timescale.js @@ -0,0 +1,145 @@ +/* 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 { + getFormatStr, +} = require("resource://devtools/client/inspector/animation/utils/l10n.js"); + +// If total duration for all animations is eqaul to or less than +// TIME_FORMAT_MAX_DURATION_IN_MS, the text which expresses time is in milliseconds, +// and seconds otherwise. Use in formatTime function. +const TIME_FORMAT_MAX_DURATION_IN_MS = 4000; + +/** + * TimeScale object holds the total duration, start time and end time and zero position + * time information for all animations which should be displayed, and is used to calculate + * the displayed area for each animation. + */ +class TimeScale { + constructor(animations) { + let resultCurrentTime = -Number.MAX_VALUE; + let resultMinStartTime = Infinity; + let resultMaxEndTime = 0; + let resultZeroPositionTime = 0; + + for (const animation of animations) { + const { + currentTime, + currentTimeAtCreated, + delay, + endTime, + startTimeAtCreated, + } = animation.state.absoluteValues; + let { startTime } = animation.state.absoluteValues; + + const negativeDelay = Math.min(delay, 0); + let zeroPositionTime = 0; + + // To shift the zero position time is the following two patterns. + // * Animation has negative current time which is smaller than negative dleay. + // * Animation has negative delay. + // Furthermore, we should override the zero position time if we will need to + // expand the duration due to this negative current time or negative delay of + // this target animation. + if (currentTimeAtCreated < negativeDelay) { + startTime = startTimeAtCreated; + zeroPositionTime = Math.abs(currentTimeAtCreated); + } else if (negativeDelay < 0) { + zeroPositionTime = Math.abs(negativeDelay); + } + + if (startTime < resultMinStartTime) { + resultMinStartTime = startTime; + // Override the previous calculated zero position only if the duration will be + // expanded. + resultZeroPositionTime = zeroPositionTime; + } else { + resultZeroPositionTime = Math.max( + resultZeroPositionTime, + zeroPositionTime + ); + } + + resultMaxEndTime = Math.max(resultMaxEndTime, endTime); + resultCurrentTime = Math.max(resultCurrentTime, currentTime); + } + + this.minStartTime = resultMinStartTime; + this.maxEndTime = resultMaxEndTime; + this.currentTime = resultCurrentTime; + this.zeroPositionTime = resultZeroPositionTime; + } + + /** + * Convert a distance in % to a time, in the current time scale. The time + * will be relative to the zero position time. + * i.e., If zeroPositionTime will be negative and specified time is shorter + * than the absolute value of zero position time, relative time will be + * negative time. + * + * @param {Number} distance + * @return {Number} + */ + distanceToRelativeTime(distance) { + return (this.getDuration() * distance) / 100 - this.zeroPositionTime; + } + + /** + * Depending on the time scale, format the given time as milliseconds or + * seconds. + * + * @param {Number} time + * @return {String} The formatted time string. + */ + formatTime(time) { + // Ignore negative zero + if (Math.abs(time) < 1 / 1000) { + time = 0.0; + } + + // Format in milliseconds if the total duration is short enough. + if (this.getDuration() <= TIME_FORMAT_MAX_DURATION_IN_MS) { + return getFormatStr("timeline.timeGraduationLabel", time.toFixed(0)); + } + + // Otherwise format in seconds. + return getFormatStr("player.timeLabel", (time / 1000).toFixed(1)); + } + + /** + * Return entire animations duration. + * + * @return {Number} duration + */ + getDuration() { + return this.maxEndTime - this.minStartTime; + } + + /** + * Return current time of this time scale represents. + * + * @return {Number} + */ + getCurrentTime() { + return this.currentTime - this.minStartTime; + } + + /** + * Return end time of given animation. + * This time does not include playbackRate and cratedTime. + * Also, if the animation has infinite iterations, this returns Infinity. + * + * @param {Object} animation + * @return {Numbber} end time + */ + getEndTime({ state }) { + return state.iterationCount + ? state.delay + state.duration * state.iterationCount + state.endDelay + : Infinity; + } +} + +module.exports = TimeScale; diff --git a/devtools/client/inspector/animation/utils/utils.js b/devtools/client/inspector/animation/utils/utils.js new file mode 100644 index 0000000000..9040c27213 --- /dev/null +++ b/devtools/client/inspector/animation/utils/utils.js @@ -0,0 +1,70 @@ +/* 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"; + +// The maximum number of times we can loop before we find the optimal time interval in the +// timeline graph. +const OPTIMAL_TIME_INTERVAL_MAX_ITERS = 100; +// Time graduations should be multiple of one of these number. +const OPTIMAL_TIME_INTERVAL_MULTIPLES = [1, 2.5, 5]; + +/** + * Find the optimal interval between time graduations in the animation timeline + * graph based on a minimum time interval. + * + * @param {Number} minTimeInterval + * Minimum time in ms in one interval + * @return {Number} The optimal interval time in ms + */ +function findOptimalTimeInterval(minTimeInterval) { + if (!minTimeInterval) { + return 0; + } + + let numIters = 0; + let multiplier = 1; + let interval; + + while (true) { + for (let i = 0; i < OPTIMAL_TIME_INTERVAL_MULTIPLES.length; i++) { + interval = OPTIMAL_TIME_INTERVAL_MULTIPLES[i] * multiplier; + + if (minTimeInterval <= interval) { + return interval; + } + } + + if (++numIters > OPTIMAL_TIME_INTERVAL_MAX_ITERS) { + return interval; + } + + multiplier *= 10; + } +} + +/** + * Check whether or not the given list of animations has an iteration count of infinite. + * + * @param {Array} animations. + * @return {Boolean} true if there is an animation in the list of animations + * whose animation iteration count is infinite. + */ +function hasAnimationIterationCountInfinite(animations) { + return animations.some(({ state }) => !state.iterationCount); +} + +/** + * Check wether the animations are running at least one. + * + * @param {Array} animations. + * @return {Boolean} true: running + */ +function hasRunningAnimation(animations) { + return animations.some(({ state }) => state.playState === "running"); +} + +exports.findOptimalTimeInterval = findOptimalTimeInterval; +exports.hasAnimationIterationCountInfinite = hasAnimationIterationCountInfinite; +exports.hasRunningAnimation = hasRunningAnimation; |