mirror of
https://github.com/roytam1/UXP.git
synced 2026-07-19 13:38:55 +00:00
503 lines
15 KiB
JavaScript
503 lines
15 KiB
JavaScript
/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
|
|
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
"use strict";
|
|
|
|
const EventEmitter = require("devtools/shared/event-emitter");
|
|
const {
|
|
createNode,
|
|
findOptimalTimeInterval,
|
|
TimeScale
|
|
} = require("devtools/client/animationinspector/utils");
|
|
const {AnimationDetails} = require("devtools/client/animationinspector/components/animation-details");
|
|
const {AnimationTargetNode} = require("devtools/client/animationinspector/components/animation-target-node");
|
|
const {AnimationTimeBlock} = require("devtools/client/animationinspector/components/animation-time-block");
|
|
|
|
// The minimum spacing between 2 time graduation headers in the timeline (px).
|
|
const TIME_GRADUATION_MIN_SPACING = 40;
|
|
// When the container window is resized, the timeline background gets refreshed,
|
|
// but only after a timer, and the timer is reset if the window is continuously
|
|
// resized.
|
|
const TIMELINE_BACKGROUND_RESIZE_DEBOUNCE_TIMER = 50;
|
|
|
|
/**
|
|
* UI component responsible for displaying a timeline for animations.
|
|
* The timeline is essentially a graph with time along the x axis and animations
|
|
* along the y axis.
|
|
* The time is represented with a graduation header at the top and a current
|
|
* time play head.
|
|
* Animations are organized by lines, with a left margin containing the preview
|
|
* of the target DOM element the animation applies to.
|
|
* The current time play head can be moved by clicking/dragging in the header.
|
|
* when this happens, the component emits "current-data-changed" events with the
|
|
* new time and state of the timeline.
|
|
*
|
|
* @param {InspectorPanel} inspector.
|
|
* @param {Object} serverTraits The list of server-side capabilities.
|
|
*/
|
|
function AnimationsTimeline(inspector, serverTraits) {
|
|
this.animations = [];
|
|
this.targetNodes = [];
|
|
this.timeBlocks = [];
|
|
this.details = [];
|
|
this.inspector = inspector;
|
|
this.serverTraits = serverTraits;
|
|
|
|
this.onAnimationStateChanged = this.onAnimationStateChanged.bind(this);
|
|
this.onScrubberMouseDown = this.onScrubberMouseDown.bind(this);
|
|
this.onScrubberMouseUp = this.onScrubberMouseUp.bind(this);
|
|
this.onScrubberMouseOut = this.onScrubberMouseOut.bind(this);
|
|
this.onScrubberMouseMove = this.onScrubberMouseMove.bind(this);
|
|
this.onAnimationSelected = this.onAnimationSelected.bind(this);
|
|
this.onWindowResize = this.onWindowResize.bind(this);
|
|
this.onFrameSelected = this.onFrameSelected.bind(this);
|
|
|
|
EventEmitter.decorate(this);
|
|
}
|
|
|
|
exports.AnimationsTimeline = AnimationsTimeline;
|
|
|
|
AnimationsTimeline.prototype = {
|
|
init: function (containerEl) {
|
|
this.win = containerEl.ownerDocument.defaultView;
|
|
|
|
this.rootWrapperEl = createNode({
|
|
parent: containerEl,
|
|
attributes: {
|
|
"class": "animation-timeline"
|
|
}
|
|
});
|
|
|
|
let scrubberContainer = createNode({
|
|
parent: this.rootWrapperEl,
|
|
attributes: {"class": "scrubber-wrapper"}
|
|
});
|
|
|
|
this.scrubberEl = createNode({
|
|
parent: scrubberContainer,
|
|
attributes: {
|
|
"class": "scrubber"
|
|
}
|
|
});
|
|
|
|
this.scrubberHandleEl = createNode({
|
|
parent: this.scrubberEl,
|
|
attributes: {
|
|
"class": "scrubber-handle"
|
|
}
|
|
});
|
|
this.scrubberHandleEl.addEventListener("mousedown",
|
|
this.onScrubberMouseDown);
|
|
|
|
this.headerWrapper = createNode({
|
|
parent: this.rootWrapperEl,
|
|
attributes: {
|
|
"class": "header-wrapper"
|
|
}
|
|
});
|
|
|
|
this.timeHeaderEl = createNode({
|
|
parent: this.headerWrapper,
|
|
attributes: {
|
|
"class": "time-header track-container"
|
|
}
|
|
});
|
|
|
|
this.timeHeaderEl.addEventListener("mousedown",
|
|
this.onScrubberMouseDown);
|
|
|
|
this.timeTickEl = createNode({
|
|
parent: this.rootWrapperEl,
|
|
attributes: {
|
|
"class": "time-body track-container"
|
|
}
|
|
});
|
|
|
|
this.animationsEl = createNode({
|
|
parent: this.rootWrapperEl,
|
|
nodeType: "ul",
|
|
attributes: {
|
|
"class": "animations"
|
|
}
|
|
});
|
|
|
|
this.win.addEventListener("resize",
|
|
this.onWindowResize);
|
|
},
|
|
|
|
destroy: function () {
|
|
this.stopAnimatingScrubber();
|
|
this.unrender();
|
|
|
|
this.win.removeEventListener("resize",
|
|
this.onWindowResize);
|
|
this.timeHeaderEl.removeEventListener("mousedown",
|
|
this.onScrubberMouseDown);
|
|
this.scrubberHandleEl.removeEventListener("mousedown",
|
|
this.onScrubberMouseDown);
|
|
|
|
this.rootWrapperEl.remove();
|
|
this.animations = [];
|
|
|
|
this.rootWrapperEl = null;
|
|
this.timeHeaderEl = null;
|
|
this.animationsEl = null;
|
|
this.scrubberEl = null;
|
|
this.scrubberHandleEl = null;
|
|
this.win = null;
|
|
this.inspector = null;
|
|
this.serverTraits = null;
|
|
},
|
|
|
|
/**
|
|
* Destroy sub-components that have been created and stored on this instance.
|
|
* @param {String} name An array of components will be expected in this[name]
|
|
* @param {Array} handlers An option list of event handlers information that
|
|
* should be used to remove these handlers.
|
|
*/
|
|
destroySubComponents: function (name, handlers = []) {
|
|
for (let component of this[name]) {
|
|
for (let {event, fn} of handlers) {
|
|
component.off(event, fn);
|
|
}
|
|
component.destroy();
|
|
}
|
|
this[name] = [];
|
|
},
|
|
|
|
unrender: function () {
|
|
for (let animation of this.animations) {
|
|
animation.off("changed", this.onAnimationStateChanged);
|
|
}
|
|
this.stopAnimatingScrubber();
|
|
TimeScale.reset();
|
|
this.destroySubComponents("targetNodes");
|
|
this.destroySubComponents("timeBlocks");
|
|
this.destroySubComponents("details", [{
|
|
event: "frame-selected",
|
|
fn: this.onFrameSelected
|
|
}]);
|
|
this.animationsEl.innerHTML = "";
|
|
},
|
|
|
|
onWindowResize: function () {
|
|
// Don't do anything if the root element has a width of 0
|
|
if (this.rootWrapperEl.offsetWidth === 0) {
|
|
return;
|
|
}
|
|
|
|
if (this.windowResizeTimer) {
|
|
this.win.clearTimeout(this.windowResizeTimer);
|
|
}
|
|
|
|
this.windowResizeTimer = this.win.setTimeout(() => {
|
|
this.drawHeaderAndBackground();
|
|
}, TIMELINE_BACKGROUND_RESIZE_DEBOUNCE_TIMER);
|
|
},
|
|
|
|
onAnimationSelected: function (e, animation) {
|
|
let index = this.animations.indexOf(animation);
|
|
if (index === -1) {
|
|
return;
|
|
}
|
|
|
|
let el = this.rootWrapperEl;
|
|
let animationEl = el.querySelectorAll(".animation")[index];
|
|
let propsEl = el.querySelectorAll(".animated-properties")[index];
|
|
|
|
// Toggle the selected state on this animation.
|
|
animationEl.classList.toggle("selected");
|
|
propsEl.classList.toggle("selected");
|
|
|
|
// Render the details component for this animation if it was shown.
|
|
if (animationEl.classList.contains("selected")) {
|
|
this.details[index].render(animation);
|
|
this.emit("animation-selected", animation);
|
|
} else {
|
|
this.emit("animation-unselected", animation);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* When a frame gets selected, move the scrubber to the corresponding position
|
|
*/
|
|
onFrameSelected: function (e, {x}) {
|
|
this.moveScrubberTo(x, true);
|
|
},
|
|
|
|
onScrubberMouseDown: function (e) {
|
|
this.moveScrubberTo(e.pageX);
|
|
this.win.addEventListener("mouseup", this.onScrubberMouseUp);
|
|
this.win.addEventListener("mouseout", this.onScrubberMouseOut);
|
|
this.win.addEventListener("mousemove", this.onScrubberMouseMove);
|
|
|
|
// Prevent text selection while dragging.
|
|
e.preventDefault();
|
|
},
|
|
|
|
onScrubberMouseUp: function () {
|
|
this.cancelTimeHeaderDragging();
|
|
},
|
|
|
|
onScrubberMouseOut: function (e) {
|
|
// Check that mouseout happened on the window itself, and if yes, cancel
|
|
// the dragging.
|
|
if (!this.win.document.contains(e.relatedTarget)) {
|
|
this.cancelTimeHeaderDragging();
|
|
}
|
|
},
|
|
|
|
cancelTimeHeaderDragging: function () {
|
|
this.win.removeEventListener("mouseup", this.onScrubberMouseUp);
|
|
this.win.removeEventListener("mouseout", this.onScrubberMouseOut);
|
|
this.win.removeEventListener("mousemove", this.onScrubberMouseMove);
|
|
},
|
|
|
|
onScrubberMouseMove: function (e) {
|
|
this.moveScrubberTo(e.pageX);
|
|
},
|
|
|
|
moveScrubberTo: function (pageX, noOffset) {
|
|
this.stopAnimatingScrubber();
|
|
|
|
// The offset needs to be in % and relative to the timeline's area (so we
|
|
// subtract the scrubber's left offset, which is equal to the sidebar's
|
|
// width).
|
|
let offset = pageX;
|
|
if (!noOffset) {
|
|
offset -= this.timeHeaderEl.offsetLeft;
|
|
}
|
|
offset = offset * 100 / this.timeHeaderEl.offsetWidth;
|
|
if (offset < 0) {
|
|
offset = 0;
|
|
}
|
|
|
|
this.scrubberEl.style.left = offset + "%";
|
|
|
|
let time = TimeScale.distanceToRelativeTime(offset);
|
|
|
|
this.emit("timeline-data-changed", {
|
|
isPaused: true,
|
|
isMoving: false,
|
|
isUserDrag: true,
|
|
time: time
|
|
});
|
|
},
|
|
|
|
getCompositorStatusClassName: function (state) {
|
|
let className = state.isRunningOnCompositor
|
|
? " fast-track"
|
|
: "";
|
|
|
|
if (state.isRunningOnCompositor && state.propertyState) {
|
|
className +=
|
|
state.propertyState.some(propState => !propState.runningOnCompositor)
|
|
? " some-properties"
|
|
: " all-properties";
|
|
}
|
|
|
|
return className;
|
|
},
|
|
|
|
render: function (animations, documentCurrentTime) {
|
|
this.unrender();
|
|
|
|
this.animations = animations;
|
|
if (!this.animations.length) {
|
|
return;
|
|
}
|
|
|
|
// Loop first to set the time scale for all current animations.
|
|
for (let {state} of animations) {
|
|
TimeScale.addAnimation(state);
|
|
}
|
|
|
|
this.drawHeaderAndBackground();
|
|
|
|
for (let animation of this.animations) {
|
|
animation.on("changed", this.onAnimationStateChanged);
|
|
// Each line contains the target animated node and the animation time
|
|
// block.
|
|
let animationEl = createNode({
|
|
parent: this.animationsEl,
|
|
nodeType: "li",
|
|
attributes: {
|
|
"class": "animation " +
|
|
animation.state.type +
|
|
this.getCompositorStatusClassName(animation.state)
|
|
}
|
|
});
|
|
|
|
// Right below the line is a hidden-by-default line for displaying the
|
|
// inline keyframes.
|
|
let detailsEl = createNode({
|
|
parent: this.animationsEl,
|
|
nodeType: "li",
|
|
attributes: {
|
|
"class": "animated-properties " + animation.state.type
|
|
}
|
|
});
|
|
|
|
let details = new AnimationDetails(this.serverTraits);
|
|
details.init(detailsEl);
|
|
details.on("frame-selected", this.onFrameSelected);
|
|
this.details.push(details);
|
|
|
|
// Left sidebar for the animated node.
|
|
let animatedNodeEl = createNode({
|
|
parent: animationEl,
|
|
attributes: {
|
|
"class": "target"
|
|
}
|
|
});
|
|
|
|
// Draw the animated node target.
|
|
let targetNode = new AnimationTargetNode(this.inspector, {compact: true});
|
|
targetNode.init(animatedNodeEl);
|
|
targetNode.render(animation);
|
|
this.targetNodes.push(targetNode);
|
|
|
|
// Right-hand part contains the timeline itself (called time-block here).
|
|
let timeBlockEl = createNode({
|
|
parent: animationEl,
|
|
attributes: {
|
|
"class": "time-block track-container"
|
|
}
|
|
});
|
|
|
|
// Draw the animation time block.
|
|
let timeBlock = new AnimationTimeBlock();
|
|
timeBlock.init(timeBlockEl);
|
|
timeBlock.render(animation);
|
|
this.timeBlocks.push(timeBlock);
|
|
|
|
timeBlock.on("selected", this.onAnimationSelected);
|
|
}
|
|
|
|
// Use the document's current time to position the scrubber (if the server
|
|
// doesn't provide it, hide the scrubber entirely).
|
|
// Note that because the currentTime was sent via the protocol, some time
|
|
// may have gone by since then, and so the scrubber might be a bit late.
|
|
if (!documentCurrentTime) {
|
|
this.scrubberEl.style.display = "none";
|
|
} else {
|
|
this.scrubberEl.style.display = "block";
|
|
this.startAnimatingScrubber(this.wasRewound()
|
|
? TimeScale.minStartTime
|
|
: documentCurrentTime);
|
|
}
|
|
},
|
|
|
|
isAtLeastOneAnimationPlaying: function () {
|
|
return this.animations.some(({state}) => state.playState === "running");
|
|
},
|
|
|
|
wasRewound: function () {
|
|
return !this.isAtLeastOneAnimationPlaying() &&
|
|
this.animations.every(({state}) => state.currentTime === 0);
|
|
},
|
|
|
|
hasInfiniteAnimations: function () {
|
|
return this.animations.some(({state}) => !state.iterationCount);
|
|
},
|
|
|
|
startAnimatingScrubber: function (time) {
|
|
let isOutOfBounds = time < TimeScale.minStartTime ||
|
|
time > TimeScale.maxEndTime;
|
|
let isAllPaused = !this.isAtLeastOneAnimationPlaying();
|
|
let hasInfinite = this.hasInfiniteAnimations();
|
|
|
|
let x = TimeScale.startTimeToDistance(time);
|
|
if (x > 100 && !hasInfinite) {
|
|
x = 100;
|
|
}
|
|
this.scrubberEl.style.left = x + "%";
|
|
|
|
// Only stop the scrubber if it's out of bounds or all animations have been
|
|
// paused, but not if at least an animation is infinite.
|
|
if (isAllPaused || (isOutOfBounds && !hasInfinite)) {
|
|
this.stopAnimatingScrubber();
|
|
this.emit("timeline-data-changed", {
|
|
isPaused: !this.isAtLeastOneAnimationPlaying(),
|
|
isMoving: false,
|
|
isUserDrag: false,
|
|
time: TimeScale.distanceToRelativeTime(x)
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.emit("timeline-data-changed", {
|
|
isPaused: false,
|
|
isMoving: true,
|
|
isUserDrag: false,
|
|
time: TimeScale.distanceToRelativeTime(x)
|
|
});
|
|
|
|
let now = this.win.performance.now();
|
|
this.rafID = this.win.requestAnimationFrame(() => {
|
|
if (!this.rafID) {
|
|
// In case the scrubber was stopped in the meantime.
|
|
return;
|
|
}
|
|
this.startAnimatingScrubber(time + this.win.performance.now() - now);
|
|
});
|
|
},
|
|
|
|
stopAnimatingScrubber: function () {
|
|
if (this.rafID) {
|
|
this.win.cancelAnimationFrame(this.rafID);
|
|
this.rafID = null;
|
|
}
|
|
},
|
|
|
|
onAnimationStateChanged: function () {
|
|
// For now, simply re-render the component. The animation front's state has
|
|
// already been updated.
|
|
this.render(this.animations);
|
|
},
|
|
|
|
drawHeaderAndBackground: function () {
|
|
let width = this.timeHeaderEl.offsetWidth;
|
|
let animationDuration = TimeScale.maxEndTime - TimeScale.minStartTime;
|
|
let minTimeInterval = TIME_GRADUATION_MIN_SPACING *
|
|
animationDuration / width;
|
|
let intervalLength = findOptimalTimeInterval(minTimeInterval);
|
|
let intervalWidth = intervalLength * width / animationDuration;
|
|
|
|
// And the time graduation header.
|
|
this.timeHeaderEl.innerHTML = "";
|
|
this.timeTickEl.innerHTML = "";
|
|
|
|
for (let i = 0; i <= width / intervalWidth; i++) {
|
|
let pos = 100 * i * intervalWidth / width;
|
|
|
|
// This element is the header of time tick for displaying animation
|
|
// duration time.
|
|
createNode({
|
|
parent: this.timeHeaderEl,
|
|
nodeType: "span",
|
|
attributes: {
|
|
"class": "header-item",
|
|
"style": `left:${pos}%`
|
|
},
|
|
textContent: TimeScale.formatTime(TimeScale.distanceToRelativeTime(pos))
|
|
});
|
|
|
|
// This element is displayed as a vertical line separator corresponding
|
|
// the header of time tick for indicating time slice for animation
|
|
// iterations.
|
|
createNode({
|
|
parent: this.timeTickEl,
|
|
nodeType: "span",
|
|
attributes: {
|
|
"class": "time-tick",
|
|
"style": `left:${pos}%`
|
|
}
|
|
});
|
|
}
|
|
}
|
|
};
|