Skip to content

Commit c3b3afe

Browse files
ergunshDevtools-frontend LUCI CQ
authored andcommitted
[Animations] Render a jump-to icon for animations in styles tab
Fixed: 349566091 Change-Id: Id8a96300c8e9a384ebfae98a1eddd8666548ac02 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/5971493 Reviewed-by: Philip Pfaffe <[email protected]> Reviewed-by: Changhao Han <[email protected]> Commit-Queue: Ergün Erdoğmuş <[email protected]>
1 parent 2357b94 commit c3b3afe

File tree

9 files changed

+273
-5
lines changed

9 files changed

+273
-5
lines changed

front_end/core/common/Revealer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ const UIStrings = {
4646
* @description The UI destination when revealing loaded resources through the Developer Resources Panel
4747
*/
4848
developerResourcesPanel: 'Developer Resources panel',
49+
/**
50+
* @description The UI destination when revealing loaded resources through the Animations panel
51+
*/
52+
animationsPanel: 'Animations panel',
4953
};
5054
const str_ = i18n.i18n.registerUIStrings('core/common/Revealer.ts', UIStrings);
5155
const i18nLazyString = i18n.i18n.getLazilyComputedLocalizedString.bind(undefined, str_);
@@ -177,6 +181,7 @@ export const RevealerDestination = {
177181
APPLICATION_PANEL: i18nLazyString(UIStrings.applicationPanel),
178182
SOURCES_PANEL: i18nLazyString(UIStrings.sourcesPanel),
179183
MEMORY_INSPECTOR_PANEL: i18nLazyString(UIStrings.memoryInspectorPanel),
184+
ANIMATIONS_PANEL: i18nLazyString(UIStrings.animationsPanel),
180185
};
181186

182187
export type RevealerDestination = () => Platform.UIString.LocalizedString;

front_end/core/sdk/AnimationModel.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,32 @@ import {
1212

1313
import * as SDK from './sdk.js';
1414

15+
function createAnimationPayload(payload: Partial<Protocol.Animation.Animation>): Protocol.Animation.Animation {
16+
return {
17+
id: '1',
18+
name: 'animation-name',
19+
pausedState: false,
20+
playbackRate: 1,
21+
startTime: 0,
22+
currentTime: 0,
23+
type: Protocol.Animation.AnimationType.CSSAnimation,
24+
playState: 'running',
25+
...payload,
26+
source: {
27+
backendNodeId: 1 as Protocol.DOM.BackendNodeId,
28+
delay: 0,
29+
endDelay: 0,
30+
iterationStart: 0,
31+
iterations: 1,
32+
duration: 100,
33+
direction: 'forward',
34+
fill: 'forwards',
35+
easing: 'linear',
36+
...(payload.source ? payload.source : null),
37+
},
38+
};
39+
}
40+
1541
describeWithMockConnection('AnimationModel', () => {
1642
afterEach(() => {
1743
clearAllMockConnectionResponseHandlers();
@@ -24,6 +50,59 @@ describeWithMockConnection('AnimationModel', () => {
2450
});
2551
});
2652

53+
describe('getAnimationGroupForAnimation', () => {
54+
const NODE_ID = 1 as Protocol.DOM.NodeId;
55+
beforeEach(() => {
56+
const stubDOMNode = sinon.createStubInstance(SDK.DOMModel.DOMNode);
57+
stubDOMNode.id = NODE_ID;
58+
sinon.stub(SDK.AnimationModel.AnimationEffect.prototype, 'node').resolves(stubDOMNode);
59+
});
60+
61+
it('should resolve the containing animation group if the animation with given name and node id exists in the group',
62+
async () => {
63+
const target = createTarget();
64+
const model = new SDK.AnimationModel.AnimationModel(target);
65+
const animationGroupStartedPromiseWithResolvers = Promise.withResolvers<void>();
66+
model.addEventListener(SDK.AnimationModel.Events.AnimationGroupStarted, () => {
67+
animationGroupStartedPromiseWithResolvers.resolve();
68+
});
69+
void model.animationStarted(createAnimationPayload({name: 'animation-name'}));
70+
await animationGroupStartedPromiseWithResolvers.promise;
71+
72+
const receivedAnimationGroup = await model.getAnimationGroupForAnimation('animation-name', NODE_ID);
73+
assert.isNotNull(receivedAnimationGroup);
74+
});
75+
76+
it('should resolve null if there is no animations with matching name', async () => {
77+
const target = createTarget();
78+
const model = new SDK.AnimationModel.AnimationModel(target);
79+
const animationGroupStartedPromiseWithResolvers = Promise.withResolvers<void>();
80+
model.addEventListener(SDK.AnimationModel.Events.AnimationGroupStarted, () => {
81+
animationGroupStartedPromiseWithResolvers.resolve();
82+
});
83+
void model.animationStarted(createAnimationPayload({name: 'animation-name'}));
84+
await animationGroupStartedPromiseWithResolvers.promise;
85+
86+
const receivedAnimationGroup = await model.getAnimationGroupForAnimation('not-a-matching-name', NODE_ID);
87+
assert.isNull(receivedAnimationGroup);
88+
});
89+
90+
it('should resolve null if there is an animation with the same name but for a different node id', async () => {
91+
const target = createTarget();
92+
const model = new SDK.AnimationModel.AnimationModel(target);
93+
const animationGroupStartedPromiseWithResolvers = Promise.withResolvers<void>();
94+
model.addEventListener(SDK.AnimationModel.Events.AnimationGroupStarted, () => {
95+
animationGroupStartedPromiseWithResolvers.resolve();
96+
});
97+
void model.animationStarted(createAnimationPayload({name: 'animation-name'}));
98+
await animationGroupStartedPromiseWithResolvers.promise;
99+
100+
const receivedAnimationGroup =
101+
await model.getAnimationGroupForAnimation('animation-name', 9999 as Protocol.DOM.NodeId);
102+
assert.isNull(receivedAnimationGroup);
103+
});
104+
});
105+
27106
describe('AnimationImpl', () => {
28107
it('setPayload should update values returned from the relevant value functions for time based animations',
29108
async () => {

front_end/core/sdk/AnimationModel.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,21 @@ export class AnimationModel extends SDKModel<EventTypes> {
339339
return 1;
340340
}
341341

342+
async getAnimationGroupForAnimation(name: string, nodeId: Protocol.DOM.NodeId): Promise<AnimationGroup|null> {
343+
for (const animationGroup of this.animationGroups.values()) {
344+
for (const animation of animationGroup.animations()) {
345+
if (animation.name() === name) {
346+
const animationNode = await animation.source().node();
347+
if (animationNode?.id === nodeId) {
348+
return animationGroup;
349+
}
350+
}
351+
}
352+
}
353+
354+
return null;
355+
}
356+
342357
animationCanceled(id: string): void {
343358
this.#pendingAnimations.delete(id);
344359
}

front_end/panels/animation/AnimationTimeline.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -238,10 +238,27 @@ export class AnimationTimeline extends UI.Widget.VBox implements
238238
continue;
239239
}
240240

241-
this.addAnimationGroup(animationGroup);
241+
void this.addAnimationGroup(animationGroup);
242242
}
243243
}
244244

245+
#showPanelInDrawer(): void {
246+
const viewManager = UI.ViewManager.ViewManager.instance();
247+
viewManager.moveView('animations', 'drawer-view', {
248+
shouldSelectTab: true,
249+
overrideSaving: true,
250+
});
251+
}
252+
253+
async revealAnimationGroup(animationGroup: SDK.AnimationModel.AnimationGroup): Promise<void> {
254+
if (!this.#previewMap.has(animationGroup)) {
255+
await this.addAnimationGroup(animationGroup);
256+
}
257+
258+
this.#showPanelInDrawer();
259+
return this.selectAnimationGroup(animationGroup);
260+
}
261+
245262
modelAdded(animationModel: SDK.AnimationModel.AnimationModel): void {
246263
if (this.isShowing()) {
247264
this.addEventListeners(animationModel);
@@ -537,7 +554,7 @@ export class AnimationTimeline extends UI.Widget.VBox implements
537554
}
538555

539556
private animationGroupStarted({data}: Common.EventTarget.EventTargetEvent<SDK.AnimationModel.AnimationGroup>): void {
540-
this.addAnimationGroup(data);
557+
void this.addAnimationGroup(data);
541558
}
542559

543560
scheduledRedrawAfterAnimationGroupUpdatedForTest(): void {
@@ -682,15 +699,15 @@ export class AnimationTimeline extends UI.Widget.VBox implements
682699
this.previewsCreatedForTest();
683700
}
684701

685-
private addAnimationGroup(group: SDK.AnimationModel.AnimationGroup): void {
702+
private addAnimationGroup(group: SDK.AnimationModel.AnimationGroup): Promise<void> {
686703
const previewGroup = this.#previewMap.get(group);
687704
if (previewGroup) {
688705
if (this.#selectedGroup === group) {
689706
this.syncScrubber();
690707
} else {
691708
previewGroup.replay();
692709
}
693-
return;
710+
return Promise.resolve();
694711
}
695712

696713
this.#groupBuffer.sort((left, right) => left.startTime() - right.startTime());
@@ -715,7 +732,7 @@ export class AnimationTimeline extends UI.Widget.VBox implements
715732
// Batch creating preview for arrivals happening closely together to ensure
716733
// stable UI sorting in the preview container.
717734
this.#collectedGroups.push(group);
718-
void this.#createPreviewForCollectedGroupsThrottler.schedule(
735+
return this.#createPreviewForCollectedGroupsThrottler.schedule(
719736
() => Promise.resolve(this.createPreviewForCollectedGroups()));
720737
}
721738

@@ -1222,3 +1239,9 @@ export class StepTimingFunction {
12221239
return null;
12231240
}
12241241
}
1242+
1243+
export class AnimationGroupRevealer implements Common.Revealer.Revealer<SDK.AnimationModel.AnimationGroup> {
1244+
async reveal(animationGroup: SDK.AnimationModel.AnimationGroup): Promise<void> {
1245+
await AnimationTimeline.instance().revealAnimationGroup(animationGroup);
1246+
}
1247+
}

front_end/panels/animation/animation-meta.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import * as Common from '../../core/common/common.js';
56
import * as i18n from '../../core/i18n/i18n.js';
7+
import * as SDK from '../../core/sdk/sdk.js';
68
import * as UI from '../../ui/legacy/legacy.js';
79

810
import type * as Animation from './animation.js';
@@ -41,3 +43,16 @@ UI.ViewManager.registerViewExtension({
4143
return Animation.AnimationTimeline.AnimationTimeline.instance();
4244
},
4345
});
46+
47+
Common.Revealer.registerRevealer({
48+
contextTypes() {
49+
return [
50+
SDK.AnimationModel.AnimationGroup,
51+
];
52+
},
53+
destination: Common.Revealer.RevealerDestination.SOURCES_PANEL,
54+
async loadRevealer() {
55+
const Animation = await loadAnimationModule();
56+
return new Animation.AnimationTimeline.AnimationGroupRevealer();
57+
},
58+
});

front_end/panels/elements/StylePropertyTreeElement.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,96 @@ describeWithMockConnection('StylePropertyTreeElement', () => {
279279
stylePropertyTreeElement.valueElement?.querySelectorAll('devtools-link-swatch');
280280
assert.strictEqual(animationNameSwatches?.length, 2);
281281
});
282+
283+
describe('jumping to animations panel', () => {
284+
let domModel: SDK.DOMModel.DOMModel;
285+
beforeEach(() => {
286+
const target = createTarget();
287+
const domModelBeforeAssertion = target.model(SDK.DOMModel.DOMModel);
288+
assert.exists(domModelBeforeAssertion);
289+
domModel = domModelBeforeAssertion;
290+
});
291+
292+
afterEach(() => {
293+
sinon.reset();
294+
});
295+
296+
it('should render a jump-to icon when the animation with the given name exists for the node', async () => {
297+
const stubAnimationGroup = sinon.createStubInstance(SDK.AnimationModel.AnimationGroup);
298+
const getAnimationGroupForAnimationStub =
299+
sinon.stub(SDK.AnimationModel.AnimationModel.prototype, 'getAnimationGroupForAnimation')
300+
.resolves(stubAnimationGroup);
301+
const domNode = SDK.DOMModel.DOMNode.create(domModel, null, false, {
302+
nodeId: 1 as Protocol.DOM.NodeId,
303+
backendNodeId: 2 as Protocol.DOM.BackendNodeId,
304+
nodeType: Node.ELEMENT_NODE,
305+
nodeName: 'div',
306+
localName: 'div',
307+
nodeValue: '',
308+
});
309+
const stylePropertyTreeElement = getTreeElement('animation-name', 'first-keyframe, second-keyframe');
310+
sinon.stub(stylePropertyTreeElement, 'node').returns(domNode);
311+
312+
stylePropertyTreeElement.updateTitle();
313+
await Promise.all(getAnimationGroupForAnimationStub.returnValues);
314+
315+
const jumpToIcon =
316+
stylePropertyTreeElement.valueElement?.querySelector('devtools-icon.open-in-animations-panel');
317+
assert.exists(jumpToIcon);
318+
});
319+
320+
it('should clicking on the jump-to icon reveal the resolved animation group', async () => {
321+
const stubAnimationGroup = sinon.createStubInstance(SDK.AnimationModel.AnimationGroup);
322+
const revealerSpy = sinon.spy(Common.Revealer.RevealerRegistry.instance(), 'reveal');
323+
const getAnimationGroupForAnimationStub =
324+
sinon.stub(SDK.AnimationModel.AnimationModel.prototype, 'getAnimationGroupForAnimation')
325+
.resolves(stubAnimationGroup);
326+
const domNode = SDK.DOMModel.DOMNode.create(domModel, null, false, {
327+
nodeId: 1 as Protocol.DOM.NodeId,
328+
backendNodeId: 2 as Protocol.DOM.BackendNodeId,
329+
nodeType: Node.ELEMENT_NODE,
330+
nodeName: 'div',
331+
localName: 'div',
332+
nodeValue: '',
333+
});
334+
const stylePropertyTreeElement = getTreeElement('animation-name', 'first-keyframe, second-keyframe');
335+
sinon.stub(stylePropertyTreeElement, 'node').returns(domNode);
336+
337+
stylePropertyTreeElement.updateTitle();
338+
await Promise.all(getAnimationGroupForAnimationStub.returnValues);
339+
340+
const jumpToIcon =
341+
stylePropertyTreeElement.valueElement?.querySelector('devtools-icon.open-in-animations-panel');
342+
jumpToIcon?.dispatchEvent(new Event('mouseup'));
343+
assert.isTrue(
344+
revealerSpy.calledWith(stubAnimationGroup),
345+
'Common.Revealer.reveal is not called for the animation group');
346+
});
347+
348+
it('should not render a jump-to icon when the animation with the given name does not exist for the node',
349+
async () => {
350+
const getAnimationGroupForAnimationStub =
351+
sinon.stub(SDK.AnimationModel.AnimationModel.prototype, 'getAnimationGroupForAnimation')
352+
.resolves(null);
353+
const domNode = SDK.DOMModel.DOMNode.create(domModel, null, false, {
354+
nodeId: 1 as Protocol.DOM.NodeId,
355+
backendNodeId: 2 as Protocol.DOM.BackendNodeId,
356+
nodeType: Node.ELEMENT_NODE,
357+
nodeName: 'div',
358+
localName: 'div',
359+
nodeValue: '',
360+
});
361+
const stylePropertyTreeElement = getTreeElement('animation-name', 'first-keyframe, second-keyframe');
362+
sinon.stub(stylePropertyTreeElement, 'node').returns(domNode);
363+
364+
stylePropertyTreeElement.updateTitle();
365+
await Promise.all(getAnimationGroupForAnimationStub.returnValues);
366+
367+
const jumpToIcon =
368+
stylePropertyTreeElement.valueElement?.querySelector('devtools-icon.open-in-animations-panel');
369+
assert.notExists(jumpToIcon);
370+
});
371+
});
282372
});
283373
});
284374

front_end/panels/elements/StylePropertyTreeElement.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,33 @@ export class LinkableNameRenderer implements MatchRenderer<LinkableNameMatch> {
757757
jslogContext,
758758
};
759759

760+
if (match.propertyName === LinkableNameProperties.ANIMATION ||
761+
match.propertyName === LinkableNameProperties.ANIMATION_NAME) {
762+
const el = document.createElement('span');
763+
el.appendChild(swatch);
764+
765+
const node = this.#treeElement.node();
766+
if (node) {
767+
const animationModel = node.domModel().target().model(SDK.AnimationModel.AnimationModel);
768+
void animationModel?.getAnimationGroupForAnimation(match.text, node.id).then(maybeAnimationGroup => {
769+
if (!maybeAnimationGroup) {
770+
return;
771+
}
772+
773+
const icon = IconButton.Icon.create('open-externally', 'open-in-animations-panel');
774+
icon.setAttribute('jslog', `${VisualLogging.link('open-in-animations-panel').track({click: true})}`);
775+
icon.setAttribute('role', 'button');
776+
icon.addEventListener('mouseup', ev => {
777+
ev.consume(true);
778+
779+
void Common.Revealer.reveal(maybeAnimationGroup);
780+
});
781+
el.insertBefore(icon, swatch);
782+
});
783+
}
784+
return [el];
785+
}
786+
760787
return [swatch];
761788
}
762789

front_end/panels/elements/stylePropertiesTreeOutline.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,3 +275,16 @@ devtools-css-angle,
275275
devtools-css-length {
276276
display: inline-block;
277277
}
278+
279+
devtools-icon.open-in-animations-panel {
280+
position: relative;
281+
transform: scale(0.7);
282+
margin: -5px -2px -3px -4px;
283+
user-select: none;
284+
color: var(--icon-default);
285+
cursor: default;
286+
287+
&:hover {
288+
color: var(--icon-default-hover);
289+
}
290+
}

front_end/ui/visual_logging/KnownContextValues.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2167,6 +2167,7 @@ export const knownContextValues = new Set([
21672167
'opacity',
21682168
'open-ai-settings',
21692169
'open-folder',
2170+
'open-in-animations-panel',
21702171
'open-in-containing-folder',
21712172
'open-in-new-tab',
21722173
'open-link-handler',

0 commit comments

Comments
 (0)