chrome-devtools-frontend
Version:
Chrome DevTools UI
522 lines (460 loc) • 21 kB
text/typescript
// Copyright (c) 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import type * as SDK from '../../core/sdk/sdk.js';
import * as InlineEditor from '../../ui/legacy/components/inline_editor/inline_editor.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import {type AnimationTimeline, StepTimingFunction} from './AnimationTimeline.js';
const UIStrings = {
/**
*@description Title of the first and last points of an animation
*/
animationEndpointSlider: 'Animation Endpoint slider',
/**
*@description Title of an Animation Keyframe point
*/
animationKeyframeSlider: 'Animation Keyframe slider',
/**
*@description Title of an animation keyframe group
*@example {anilogo} PH1
*/
sSlider: '{PH1} slider',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/animation/AnimationUI.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
interface CachedElement {
group: HTMLElement|null;
animationLine: HTMLElement|null;
keyframePoints: Record<number, HTMLElement>;
keyframeRender: Record<number, HTMLElement>;
}
export class AnimationUI {
#animationInternal: SDK.AnimationModel.AnimationImpl;
#timeline: AnimationTimeline;
#keyframes?: SDK.AnimationModel.KeyframeStyle[];
#nameElement: HTMLElement;
readonly #svg: Element;
#activeIntervalGroup: Element;
#cachedElements: CachedElement[];
#movementInMs: number;
#keyboardMovementRateMs: number;
#color: string;
#node?: SDK.DOMModel.DOMNode|null;
#delayLine?: Element;
#endDelayLine?: Element;
#tailGroup?: Element;
#mouseEventType?: Events;
#keyframeMoved?: number|null;
#downMouseX?: number;
constructor(animation: SDK.AnimationModel.AnimationImpl, timeline: AnimationTimeline, parentElement: Element) {
this.#animationInternal = animation;
this.#timeline = timeline;
const keyframesRule = this.#animationInternal.source().keyframesRule();
if (keyframesRule) {
this.#keyframes = keyframesRule.keyframes();
if (animation.viewOrScrollTimeline() && animation.playbackRate() < 0) {
this.#keyframes.reverse();
}
}
this.#nameElement = parentElement.createChild('div', 'animation-name');
this.#nameElement.textContent = this.#animationInternal.name();
this.#svg = UI.UIUtils.createSVGChild(parentElement, 'svg', 'animation-ui');
this.#svg.setAttribute('height', Options.AnimationSVGHeight.toString());
(this.#svg as HTMLElement).style.marginLeft = '-' + Options.AnimationMargin + 'px';
this.#svg.addEventListener('contextmenu', this.onContextMenu.bind(this));
this.#activeIntervalGroup = UI.UIUtils.createSVGChild(this.#svg, 'g');
this.#activeIntervalGroup.setAttribute('jslog', `${VisualLogging.animationClip().track({drag: true})}`);
if (!this.#animationInternal.viewOrScrollTimeline()) {
UI.UIUtils.installDragHandle(
this.#activeIntervalGroup, this.mouseDown.bind(this, Events.ANIMATION_DRAG, null), this.mouseMove.bind(this),
this.mouseUp.bind(this), '-webkit-grabbing', '-webkit-grab');
AnimationUI.installDragHandleKeyboard(
this.#activeIntervalGroup, this.keydownMove.bind(this, Events.ANIMATION_DRAG, null));
}
this.#cachedElements = [];
this.#movementInMs = 0;
this.#keyboardMovementRateMs = 50;
this.#color = AnimationUI.colorForAnimation(this.#animationInternal);
}
static colorForAnimation(animation: SDK.AnimationModel.AnimationImpl): string {
const names = Array.from(Colors.keys());
const hashCode = Platform.StringUtilities.hashCode(animation.name() || animation.id());
const cappedHashCode = hashCode % names.length;
const colorName = names[cappedHashCode];
const color = Colors.get(colorName);
if (!color) {
throw new Error('Unable to locate color');
}
return color.asString(Common.Color.Format.RGB) || '';
}
static installDragHandleKeyboard(element: Element, elementDrag: (arg0: Event) => void): void {
element.addEventListener('keydown', elementDrag, false);
}
animation(): SDK.AnimationModel.AnimationImpl {
return this.#animationInternal;
}
get nameElement(): HTMLElement {
return this.#nameElement;
}
get svg(): Element {
return this.#svg;
}
setNode(node: SDK.DOMModel.DOMNode|null): void {
this.#node = node;
}
private createLine(parentElement: HTMLElement, className: string): Element {
const line = UI.UIUtils.createSVGChild(parentElement, 'line', className);
line.setAttribute('x1', Options.AnimationMargin.toString());
line.setAttribute('y1', Options.AnimationHeight.toString());
line.setAttribute('y2', Options.AnimationHeight.toString());
(line as HTMLElement).style.stroke = this.#color;
return line;
}
private drawAnimationLine(iteration: number, parentElement: HTMLElement): void {
const cache = this.#cachedElements[iteration];
if (!cache.animationLine) {
cache.animationLine = (this.createLine(parentElement, 'animation-line') as HTMLElement);
}
if (!cache.animationLine) {
return;
}
cache.animationLine.setAttribute(
'x2', (this.duration() * this.#timeline.pixelTimeRatio() + Options.AnimationMargin).toFixed(2));
}
private drawDelayLine(parentElement: HTMLElement): void {
if (!this.#delayLine || !this.#endDelayLine) {
this.#delayLine = this.createLine(parentElement, 'animation-delay-line');
this.#endDelayLine = this.createLine(parentElement, 'animation-delay-line');
}
const fill = this.#animationInternal.source().fill();
this.#delayLine.classList.toggle('animation-fill', fill === 'backwards' || fill === 'both');
const margin = Options.AnimationMargin;
this.#delayLine.setAttribute('x1', margin.toString());
this.#delayLine.setAttribute('x2', (this.delayOrStartTime() * this.#timeline.pixelTimeRatio() + margin).toFixed(2));
const forwardsFill = fill === 'forwards' || fill === 'both';
this.#endDelayLine.classList.toggle('animation-fill', forwardsFill);
const leftMargin = Math.min(
this.#timeline.width(),
(this.delayOrStartTime() + this.duration() * this.#animationInternal.source().iterations()) *
this.#timeline.pixelTimeRatio());
(this.#endDelayLine as HTMLElement).style.transform = 'translateX(' + leftMargin.toFixed(2) + 'px)';
this.#endDelayLine.setAttribute('x1', margin.toString());
this.#endDelayLine.setAttribute(
'x2',
forwardsFill ?
(this.#timeline.width() - leftMargin + margin).toFixed(2) :
(this.#animationInternal.source().endDelay() * this.#timeline.pixelTimeRatio() + margin).toFixed(2));
}
private drawPoint(iteration: number, parentElement: Element, x: number, keyframeIndex: number, attachEvents: boolean):
void {
if (this.#cachedElements[iteration].keyframePoints[keyframeIndex]) {
this.#cachedElements[iteration].keyframePoints[keyframeIndex].setAttribute('cx', x.toFixed(2));
return;
}
const circle =
(UI.UIUtils.createSVGChild(
parentElement, 'circle', keyframeIndex <= 0 ? 'animation-endpoint' : 'animation-keyframe-point') as
HTMLElement);
circle.setAttribute('cx', x.toFixed(2));
circle.setAttribute('cy', Options.AnimationHeight.toString());
circle.style.stroke = this.#color;
circle.setAttribute('r', (Options.AnimationMargin / 2).toString());
circle.setAttribute('jslog', `${VisualLogging.controlPoint('animations.keyframe').track({drag: true})}`);
circle.tabIndex = 0;
UI.ARIAUtils.setLabel(
circle,
keyframeIndex <= 0 ? i18nString(UIStrings.animationEndpointSlider) :
i18nString(UIStrings.animationKeyframeSlider));
if (keyframeIndex <= 0) {
circle.style.fill = this.#color;
}
this.#cachedElements[iteration].keyframePoints[keyframeIndex] = (circle);
if (!attachEvents) {
return;
}
let eventType: Events;
if (keyframeIndex === 0) {
eventType = Events.START_ENDPOINT_MOVE;
} else if (keyframeIndex === -1) {
eventType = Events.FINISH_ENDPOINT_MOVE;
} else {
eventType = Events.KEYFRAME_MOVE;
}
if (!this.animation().viewOrScrollTimeline()) {
UI.UIUtils.installDragHandle(
circle, this.mouseDown.bind(this, eventType, keyframeIndex), this.mouseMove.bind(this),
this.mouseUp.bind(this), 'ew-resize');
AnimationUI.installDragHandleKeyboard(circle, this.keydownMove.bind(this, eventType, keyframeIndex));
}
}
private renderKeyframe(
iteration: number, keyframeIndex: number, parentElement: HTMLElement, leftDistance: number, width: number,
easing: string): void {
function createStepLine(parentElement: HTMLElement, x: number, strokeColor: string): void {
const line = (UI.UIUtils.createSVGChild(parentElement, 'line') as HTMLElement);
line.setAttribute('x1', x.toString());
line.setAttribute('x2', x.toString());
line.setAttribute('y1', Options.AnimationMargin.toString());
line.setAttribute('y2', Options.AnimationHeight.toString());
line.style.stroke = strokeColor;
}
const bezier = UI.Geometry.CubicBezier.parse(easing);
const cache = this.#cachedElements[iteration].keyframeRender;
if (!cache[keyframeIndex]) {
const svg = bezier ? UI.UIUtils.createSVGChild(parentElement, 'path', 'animation-keyframe') :
UI.UIUtils.createSVGChild(parentElement, 'g', 'animation-keyframe-step');
cache[keyframeIndex] = (svg as HTMLElement);
}
const group = cache[keyframeIndex];
group.tabIndex = 0;
UI.ARIAUtils.setLabel(group, i18nString(UIStrings.sSlider, {PH1: this.#animationInternal.name()}));
group.style.transform = 'translateX(' + leftDistance.toFixed(2) + 'px)';
if (easing === 'linear') {
group.style.fill = this.#color;
const height = InlineEditor.BezierUI.Height;
group.setAttribute(
'd', ['M', 0, height, 'L', 0, 5, 'L', width.toFixed(2), 5, 'L', width.toFixed(2), height, 'Z'].join(' '));
} else if (bezier) {
group.style.fill = this.#color;
InlineEditor.BezierUI.BezierUI.drawVelocityChart(bezier, group, width);
} else {
const stepFunction = StepTimingFunction.parse(easing);
group.removeChildren();
const offsetMap: Record<string, number> = {start: 0, middle: 0.5, end: 1};
if (stepFunction) {
const offsetWeight = offsetMap[stepFunction.stepAtPosition];
for (let i = 0; i < stepFunction.steps; i++) {
createStepLine(group, (i + offsetWeight) * width / stepFunction.steps, this.#color);
}
}
}
}
redraw(): void {
const maxWidth = this.#timeline.width() - Options.AnimationMargin;
this.#svg.setAttribute('width', (maxWidth + 2 * Options.AnimationMargin).toFixed(2));
(this.#activeIntervalGroup as HTMLElement).style.transform =
'translateX(' + (this.delayOrStartTime() * this.#timeline.pixelTimeRatio()).toFixed(2) + 'px)';
this.#nameElement.style.transform = 'translateX(' +
(Math.max(this.delayOrStartTime(), 0) * this.#timeline.pixelTimeRatio() + Options.AnimationMargin).toFixed(2) +
'px)';
this.#nameElement.style.width = (this.duration() * this.#timeline.pixelTimeRatio()).toFixed(2) + 'px';
this.drawDelayLine((this.#svg as HTMLElement));
if (this.#animationInternal.type() === 'CSSTransition') {
this.renderTransition();
return;
}
this.renderIteration(this.#activeIntervalGroup, 0);
if (!this.#tailGroup) {
this.#tailGroup = UI.UIUtils.createSVGChild(this.#activeIntervalGroup, 'g', 'animation-tail-iterations');
}
const iterationWidth = this.duration() * this.#timeline.pixelTimeRatio();
let iteration;
// Some iterations are getting rendered in an invisible area if the delay is negative.
const invisibleAreaWidth =
this.delayOrStartTime() < 0 ? -this.delayOrStartTime() * this.#timeline.pixelTimeRatio() : 0;
for (iteration = 1; iteration < this.#animationInternal.source().iterations() &&
iterationWidth * (iteration - 1) < invisibleAreaWidth + this.#timeline.width() &&
(iterationWidth > 0 || this.#animationInternal.source().iterations() !== Infinity);
iteration++) {
this.renderIteration(this.#tailGroup, iteration);
}
while (iteration < this.#cachedElements.length) {
const poppedElement = this.#cachedElements.pop();
if (poppedElement?.group) {
poppedElement.group.remove();
}
}
}
private renderTransition(): void {
const activeIntervalGroup = (this.#activeIntervalGroup as HTMLElement);
if (!this.#cachedElements[0]) {
this.#cachedElements[0] = {animationLine: null, keyframePoints: {}, keyframeRender: {}, group: null};
}
this.drawAnimationLine(0, activeIntervalGroup);
this.renderKeyframe(
0, 0, activeIntervalGroup, Options.AnimationMargin, this.duration() * this.#timeline.pixelTimeRatio(),
this.#animationInternal.source().easing());
this.drawPoint(0, activeIntervalGroup, Options.AnimationMargin, 0, true);
this.drawPoint(
0, activeIntervalGroup, this.duration() * this.#timeline.pixelTimeRatio() + Options.AnimationMargin, -1, true);
}
private renderIteration(parentElement: Element, iteration: number): void {
if (!this.#cachedElements[iteration]) {
this.#cachedElements[iteration] = {
animationLine: null,
keyframePoints: {},
keyframeRender: {},
group: (UI.UIUtils.createSVGChild(parentElement, 'g') as HTMLElement),
};
}
const group = this.#cachedElements[iteration].group;
if (!group) {
return;
}
group.style.transform =
'translateX(' + (iteration * this.duration() * this.#timeline.pixelTimeRatio()).toFixed(2) + 'px)';
this.drawAnimationLine(iteration, group);
if (this.#keyframes && this.#keyframes.length > 1) {
for (let i = 0; i < this.#keyframes.length - 1; i++) {
const leftDistance =
this.offset(i) * this.duration() * this.#timeline.pixelTimeRatio() + Options.AnimationMargin;
const width = this.duration() * (this.offset(i + 1) - this.offset(i)) * this.#timeline.pixelTimeRatio();
this.renderKeyframe(iteration, i, group, leftDistance, width, this.#keyframes[i].easing());
if (i || (!i && iteration === 0)) {
this.drawPoint(iteration, group, leftDistance, i, iteration === 0);
}
}
}
this.drawPoint(
iteration, group, this.duration() * this.#timeline.pixelTimeRatio() + Options.AnimationMargin, -1,
iteration === 0);
}
private delayOrStartTime(): number {
let delay = this.#animationInternal.delayOrStartTime();
if (this.#mouseEventType === Events.ANIMATION_DRAG || this.#mouseEventType === Events.START_ENDPOINT_MOVE) {
delay += this.#movementInMs;
}
return delay;
}
private duration(): number {
let duration = this.#animationInternal.iterationDuration();
if (this.#mouseEventType === Events.FINISH_ENDPOINT_MOVE) {
duration += this.#movementInMs;
} else if (this.#mouseEventType === Events.START_ENDPOINT_MOVE) {
duration -= this.#movementInMs;
}
return Math.max(0, duration);
}
private offset(i: number): number {
if (!this.#keyframes) {
throw new Error('Unable to calculate offset; keyframes do not exist');
}
let offset = this.#keyframes[i].offsetAsNumber();
if (this.#mouseEventType === Events.KEYFRAME_MOVE && i === this.#keyframeMoved) {
console.assert(i > 0 && i < this.#keyframes.length - 1, 'First and last keyframe cannot be moved');
offset += this.#movementInMs / this.#animationInternal.iterationDuration();
offset = Math.max(offset, this.#keyframes[i - 1].offsetAsNumber());
offset = Math.min(offset, this.#keyframes[i + 1].offsetAsNumber());
}
return offset;
}
private mouseDown(mouseEventType: Events, keyframeIndex: number|null, event: Event): boolean {
const mouseEvent = (event as MouseEvent);
if (mouseEvent.buttons === 2) {
return false;
}
if (this.#svg.enclosingNodeOrSelfWithClass('animation-node-removed')) {
return false;
}
this.#mouseEventType = mouseEventType;
this.#keyframeMoved = keyframeIndex;
this.#downMouseX = mouseEvent.clientX;
event.consume(true);
const viewManagerInstance = UI.ViewManager.ViewManager.instance();
const animationLocation = viewManagerInstance.locationNameForViewId('animations');
const elementsLocation = viewManagerInstance.locationNameForViewId('elements');
// Prevents revealing the node if the animations and elements view share the same view location.
// If they share the same view location, the animations view will change to the elements view when editing an animation
if (this.#node && animationLocation !== elementsLocation) {
void Common.Revealer.reveal(this.#node);
}
return true;
}
private mouseMove(event: Event): void {
const mouseEvent = (event as MouseEvent);
this.setMovementAndRedraw((mouseEvent.clientX - (this.#downMouseX || 0)) / this.#timeline.pixelTimeRatio());
}
private setMovementAndRedraw(movement: number): void {
this.#movementInMs = movement;
if (this.delayOrStartTime() + this.duration() > this.#timeline.duration() * 0.8) {
this.#timeline.setDuration(this.#timeline.duration() * 1.2);
}
this.redraw();
}
private mouseUp(event: Event): void {
const mouseEvent = (event as MouseEvent);
this.#movementInMs = (mouseEvent.clientX - (this.#downMouseX || 0)) / this.#timeline.pixelTimeRatio();
// Commit changes
if (this.#mouseEventType === Events.KEYFRAME_MOVE) {
if (this.#keyframes && this.#keyframeMoved !== null && typeof this.#keyframeMoved !== 'undefined') {
this.#keyframes[this.#keyframeMoved].setOffset(this.offset(this.#keyframeMoved));
}
} else {
this.#animationInternal.setTiming(this.duration(), this.delayOrStartTime());
}
this.#movementInMs = 0;
this.redraw();
this.#mouseEventType = undefined;
this.#downMouseX = undefined;
this.#keyframeMoved = undefined;
}
private keydownMove(mouseEventType: Events, keyframeIndex: number|null, event: Event): void {
const keyboardEvent = (event as KeyboardEvent);
this.#mouseEventType = mouseEventType;
this.#keyframeMoved = keyframeIndex;
switch (keyboardEvent.key) {
case 'ArrowLeft':
case 'ArrowUp':
this.#movementInMs = -this.#keyboardMovementRateMs;
break;
case 'ArrowRight':
case 'ArrowDown':
this.#movementInMs = this.#keyboardMovementRateMs;
break;
default:
return;
}
if (this.#mouseEventType === Events.KEYFRAME_MOVE) {
if (this.#keyframes && this.#keyframeMoved !== null) {
this.#keyframes[this.#keyframeMoved].setOffset(this.offset(this.#keyframeMoved));
}
} else {
this.#animationInternal.setTiming(this.duration(), this.delayOrStartTime());
}
this.setMovementAndRedraw(0);
this.#mouseEventType = undefined;
this.#keyframeMoved = undefined;
event.consume(true);
}
private onContextMenu(event: Event): void {
function showContextMenu(remoteObject: SDK.RemoteObject.RemoteObject|null): void {
if (!remoteObject) {
return;
}
const contextMenu = new UI.ContextMenu.ContextMenu(event);
contextMenu.appendApplicableItems(remoteObject);
void contextMenu.show();
}
void this.#animationInternal.remoteObjectPromise().then(showContextMenu);
event.consume(true);
}
}
export const enum Events {
ANIMATION_DRAG = 'AnimationDrag',
KEYFRAME_MOVE = 'KeyframeMove',
START_ENDPOINT_MOVE = 'StartEndpointMove',
FINISH_ENDPOINT_MOVE = 'FinishEndpointMove',
}
export const Options = {
AnimationHeight: 26,
AnimationSVGHeight: 50,
AnimationMargin: 7,
EndpointsClickRegionSize: 10,
GridCanvasHeight: 40,
};
export const Colors = new Map<string, Common.Color.Color|null>([
['Purple', Common.Color.parse('#9C27B0')],
['Light Blue', Common.Color.parse('#03A9F4')],
['Deep Orange', Common.Color.parse('#FF5722')],
['Blue', Common.Color.parse('#5677FC')],
['Lime', Common.Color.parse('#CDDC39')],
['Blue Grey', Common.Color.parse('#607D8B')],
['Pink', Common.Color.parse('#E91E63')],
['Green', Common.Color.parse('#0F9D58')],
['Brown', Common.Color.parse('#795548')],
['Cyan', Common.Color.parse('#00BCD4')],
]);