diff options
Diffstat (limited to '')
44 files changed, 4325 insertions, 0 deletions
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", +) |