| // Copyright 2015 The Chromium Authors |
| // 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 Geometry from '../../models/geometry/geometry.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: SVGElement|null; |
| animationLine: SVGElement|null; |
| keyframePoints: Record<number, SVGElement>; |
| keyframeRender: Record<number, SVGElement>; |
| } |
| |
| export class AnimationUI { |
| #animation: SDK.AnimationModel.AnimationImpl; |
| #timeline: AnimationTimeline; |
| #keyframes?: SDK.AnimationModel.KeyframeStyle[]; |
| #nameElement: HTMLElement; |
| readonly #svg: SVGElement; |
| #activeIntervalGroup: SVGGElement; |
| #cachedElements: CachedElement[] = []; |
| #movementInMs = 0; |
| #keyboardMovementRateMs = 50; |
| #color: string; |
| #node?: SDK.DOMModel.DOMNode|null; |
| #delayLine?: SVGLineElement; |
| #endDelayLine?: SVGLineElement; |
| #tailGroup?: Element; |
| #mouseEventType?: Events; |
| #keyframeMoved?: number|null; |
| #downMouseX?: number; |
| |
| constructor(animation: SDK.AnimationModel.AnimationImpl, timeline: AnimationTimeline, parentElement: Element) { |
| this.#animation = animation; |
| this.#timeline = timeline; |
| |
| const keyframesRule = this.#animation.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.#animation.name(); |
| |
| this.#svg = UI.UIUtils.createSVGChild(parentElement, 'svg', 'animation-ui'); |
| this.#svg.setAttribute('height', Options.AnimationSVGHeight.toString()); |
| this.#svg.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.#animation.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.#color = AnimationUI.colorForAnimation(this.#animation); |
| } |
| |
| 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.#animation; |
| } |
| |
| 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: SVGElement, className: string): SVGLineElement { |
| 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.style.stroke = this.#color; |
| return line; |
| } |
| |
| private drawAnimationLine(iteration: number, parentElement: SVGElement): void { |
| const cache = this.#cachedElements[iteration]; |
| if (!cache.animationLine) { |
| cache.animationLine = this.createLine(parentElement, 'animation-line'); |
| } |
| if (!cache.animationLine) { |
| return; |
| } |
| |
| cache.animationLine.setAttribute( |
| 'x2', (this.duration() * this.#timeline.pixelTimeRatio() + Options.AnimationMargin).toFixed(2)); |
| } |
| |
| private drawDelayLine(parentElement: SVGElement): 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.#animation.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.#animation.source().iterations()) * |
| this.#timeline.pixelTimeRatio()); |
| this.#endDelayLine.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.#animation.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'); |
| 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: SVGElement, leftDistance: number, width: number, |
| easing: string): void { |
| function createStepLine(parentElement: SVGElement, x: number, strokeColor: string): void { |
| const line = UI.UIUtils.createSVGChild(parentElement, 'line'); |
| 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 = 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; |
| } |
| const group = cache[keyframeIndex]; |
| group.tabIndex = 0; |
| UI.ARIAUtils.setLabel(group, i18nString(UIStrings.sSlider, {PH1: this.#animation.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.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); |
| |
| if (this.#animation.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.#animation.source().iterations() && |
| iterationWidth * (iteration - 1) < invisibleAreaWidth + this.#timeline.width() && |
| (iterationWidth > 0 || this.#animation.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; |
| 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.#animation.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'), |
| }; |
| } |
| 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.#animation.delayOrStartTime(); |
| if (this.#mouseEventType === Events.ANIMATION_DRAG || this.#mouseEventType === Events.START_ENDPOINT_MOVE) { |
| delay += this.#movementInMs; |
| } |
| return delay; |
| } |
| |
| private duration(): number { |
| let duration = this.#animation.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.#animation.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.#animation.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.#animation.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.#animation.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')], |
| ]); |