Skip to content

Commit d2cc89e

Browse files
ergunshDevtools-frontend LUCI CQ
authored andcommitted
[ConsoleInsights] Add animation to streaming CI responses
This CL: * Implements the streaming animation for the MarkdownView. * Enables animation for the streaming in Console Insights. Screencast: https://screencast.googleplex.com/cast/NTgyNzAzODg4MjU2MjA0OHxjZjE1MDNhMS02MQ Bug: 375556018 Change-Id: Ibff1681df92c55ac4575c170c8fbf0ff455ef42f Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/5993652 Auto-Submit: Ergün Erdoğmuş <[email protected]> Reviewed-by: Alex Rudenko <[email protected]> Commit-Queue: Ergün Erdoğmuş <[email protected]>
1 parent ad42fef commit d2cc89e

File tree

4 files changed

+133
-32
lines changed

4 files changed

+133
-32
lines changed

front_end/panels/explain/components/ConsoleInsight.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,7 @@ export class ConsoleInsight extends HTMLElement {
584584
<main jslog=${jslog}>
585585
${
586586
this.#state.validMarkdown ? html`<devtools-markdown-view
587-
.data=${{tokens: this.#state.tokens, renderer: this.#renderer} as MarkdownView.MarkdownView.MarkdownViewData}>
587+
.data=${{tokens: this.#state.tokens, renderer: this.#renderer, animationEnabled: true} as MarkdownView.MarkdownView.MarkdownViewData}>
588588
</devtools-markdown-view>`: this.#state.explanation
589589
}
590590
<details style="--list-height: ${(this.#state.sources.length + (this.#state.isPageReloadRecommended ? 1 : 0)) * 20}px;" jslog=${VisualLogging.expand('sources').track({click: true})}>

front_end/ui/components/markdown_view/MarkdownView.test.ts

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ function getFakeToken(token: TestToken): Marked.Marked.Token {
2424
return token as unknown as Marked.Marked.Token;
2525
}
2626

27+
function renderTemplateResult(templateResult: LitHtml.TemplateResult): HTMLElement {
28+
const container = document.createElement('container');
29+
LitHtml.render(templateResult, container); // eslint-disable-line rulesdir/lit_html_host_this
30+
return container;
31+
}
32+
2733
describeWithEnvironment('MarkdownView', () => {
2834
describe('tokenizer', () => {
2935
it('tokenizers links in single quotes', () => {
@@ -65,45 +71,53 @@ describeWithEnvironment('MarkdownView', () => {
6571
const renderer = new MarkdownView.MarkdownView.MarkdownLitRenderer();
6672

6773
it('wraps paragraph tokens in <p> tags', () => {
68-
const renderResult = renderer.renderToken(getFakeToken({type: 'paragraph', tokens: []}));
69-
assert.deepStrictEqual(renderResult.strings.raw, ['<p>', '</p>']);
74+
const container = renderTemplateResult(renderer.renderToken(getFakeToken({type: 'paragraph', tokens: []})));
75+
76+
assert.exists(container.querySelector('p'));
7077
});
7178

7279
it('wraps an unordered list token in <ul> tags', () => {
73-
const renderResult = renderer.renderToken(getFakeToken({type: 'list', items: []}));
74-
assert.deepStrictEqual(renderResult.strings.raw, ['<ul>', '</ul>']);
80+
const container = renderTemplateResult(renderer.renderToken(getFakeToken({type: 'list', items: []})));
81+
82+
assert.exists(container.querySelector('ul'));
7583
});
7684

7785
it('wraps list items in <li> tags', () => {
78-
const renderResult = renderer.renderToken(getFakeToken({type: 'list_item', tokens: []}));
79-
assert.deepStrictEqual(renderResult.strings.raw, ['<li>', '</li>']);
86+
const container = renderTemplateResult(renderer.renderToken(getFakeToken({type: 'list_item', tokens: []})));
87+
assert.exists(container.querySelector('li'));
8088
});
8189

8290
it('wraps a codespan token in <code> tags', () => {
83-
const renderResult = renderer.renderToken(getFakeToken({type: 'codespan', text: 'const foo = 42;'}));
84-
assert.deepStrictEqual(renderResult.strings.raw, ['<code>', '</code>']);
85-
assert.deepStrictEqual(renderResult.values, ['const foo = 42;']);
91+
const container =
92+
renderTemplateResult(renderer.renderToken(getFakeToken({type: 'codespan', text: 'const foo = 42;'})));
93+
94+
const code = container.querySelector('code');
95+
assert.exists(code);
96+
assert.deepStrictEqual(code.textContent, 'const foo = 42;');
8697
});
8798

8899
it('renders childless text tokens as-is', () => {
89-
const renderResult = renderer.renderToken(getFakeToken({type: 'text', text: 'Simple text token'}));
90-
assert.deepStrictEqual(renderResult.values, ['Simple text token']);
100+
const container =
101+
renderTemplateResult(renderer.renderToken(getFakeToken({type: 'text', text: 'Simple text token'})));
102+
103+
assert.deepStrictEqual(container.childTextNodes().length, 1);
104+
assert.deepStrictEqual(container.childTextNodes()[0].textContent, 'Simple text token');
91105
});
92106

93107
it('renders nested text tokens correctly', () => {
94-
const renderResult = renderer.renderToken(getFakeToken({
108+
const container = renderTemplateResult(renderer.renderToken(getFakeToken({
95109
type: 'text',
96110
text: 'This text should not be rendered. Only the subtokens!',
97111
tokens: [
98112
getFakeToken({type: 'text', text: 'Nested raw text'}),
99113
getFakeToken({type: 'codespan', text: 'and a nested codespan to boot'}),
100114
],
101-
}));
115+
})));
102116

103-
const renderedParts = renderResult.values[0] as LitHtml.TemplateResult[];
104-
assert.strictEqual(renderedParts.length, 2);
105-
assert.deepStrictEqual(renderedParts[0].values, ['Nested raw text']);
106-
assert.deepStrictEqual(renderedParts[1].values, ['and a nested codespan to boot']);
117+
assert.notInclude(container.textContent, 'This text should not be rendered. Only the subtokens!');
118+
assert.include(container.textContent, 'Nested raw text');
119+
assert.exists(container.querySelector('code'));
120+
assert.deepStrictEqual(container.querySelector('code')?.textContent, 'and a nested codespan to boot');
107121
});
108122

109123
it('throws an error for invalid or unsupported token types', () => {
@@ -161,6 +175,14 @@ describeWithEnvironment('MarkdownView', () => {
161175

162176
assert.isTrue(renderResult.includes('<em'));
163177
});
178+
it('sets custom classes on the token types', () => {
179+
renderer.setCustomClasses({em: 'custom-class'});
180+
181+
const renderResult = renderer.renderToken(getFakeToken({type: 'em', text: 'em text'}));
182+
const container = renderTemplateResult(renderResult);
183+
assert.isTrue(
184+
container.querySelector('em')?.classList.contains('custom-class'), 'Expected custom-class to be applied');
185+
});
164186
});
165187

166188
describe('MarkdownInsightRenderer renderToken', () => {

front_end/ui/components/markdown_view/MarkdownView.ts

Lines changed: 77 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@ const render = LitHtml.render;
1818
export interface MarkdownViewData {
1919
tokens: Marked.Marked.Token[];
2020
renderer?: MarkdownLitRenderer;
21+
animationEnabled?: boolean;
2122
}
2223

2324
export class MarkdownView extends HTMLElement {
2425
readonly #shadow = this.attachShadow({mode: 'open'});
2526

2627
#tokenData: readonly Marked.Marked.Token[] = [];
2728
#renderer = new MarkdownLitRenderer();
29+
#animationEnabled = false;
30+
#isAnimating = false;
2831

2932
connectedCallback(): void {
3033
this.#shadow.adoptedStyleSheets = [markdownViewStyles];
@@ -35,11 +38,53 @@ export class MarkdownView extends HTMLElement {
3538
if (data.renderer) {
3639
this.#renderer = data.renderer;
3740
}
41+
42+
if (data.animationEnabled) {
43+
this.#animationEnabled = true;
44+
this.#renderer.setCustomClasses({
45+
paragraph: 'pending',
46+
heading: 'pending',
47+
list_item: 'pending',
48+
});
49+
} else {
50+
this.#animationEnabled = false;
51+
this.#renderer.setCustomClasses({});
52+
}
53+
3854
this.#update();
3955
}
4056

57+
#animate(): void {
58+
if (this.#isAnimating) {
59+
return;
60+
}
61+
62+
this.#isAnimating = true;
63+
const reveal = (): void => {
64+
const pendingElement = this.#shadow.querySelector('.pending');
65+
if (!pendingElement) {
66+
this.#isAnimating = false;
67+
return;
68+
}
69+
70+
pendingElement.addEventListener('animationend', () => {
71+
pendingElement.classList.remove('animating');
72+
reveal();
73+
}, {once: true});
74+
75+
pendingElement.classList.remove('pending');
76+
pendingElement.classList.add('animating');
77+
};
78+
79+
reveal();
80+
}
81+
4182
#update(): void {
4283
this.#render();
84+
85+
if (this.#animationEnabled) {
86+
this.#animate();
87+
}
4388
}
4489

4590
#render(): void {
@@ -66,6 +111,18 @@ declare global {
66111
* Default renderer is used for the IssuesPanel and allows only well-known images and links to be embedded.
67112
*/
68113
export class MarkdownLitRenderer {
114+
#customClasses: Record<string, string> = {};
115+
116+
setCustomClasses(customClasses: Record<Marked.Marked.Token['type'], string>): void {
117+
this.#customClasses = customClasses;
118+
}
119+
120+
#customClassMapForToken(type: Marked.Marked.Token['type']): LitHtml.Directive.DirectiveResult {
121+
return LitHtml.Directives.classMap({
122+
[this.#customClasses[type]]: this.#customClasses[type],
123+
});
124+
}
125+
69126
renderChildTokens(token: Marked.Marked.Token): LitHtml.TemplateResult[] {
70127
if ('tokens' in token && token.tokens) {
71128
return token.tokens.map(token => this.renderToken(token));
@@ -102,25 +159,27 @@ export class MarkdownLitRenderer {
102159
}
103160

104161
renderHeading(heading: Marked.Marked.Tokens.Heading): LitHtml.TemplateResult {
162+
const customClass = this.#customClassMapForToken('heading');
105163
switch (heading.depth) {
106164
case 1:
107-
return html`<h1>${this.renderText(heading)}</h1>`;
165+
return html`<h1 class=${customClass}>${this.renderText(heading)}</h1>`;
108166
case 2:
109-
return html`<h2>${this.renderText(heading)}</h2>`;
167+
return html`<h2 class=${customClass}>${this.renderText(heading)}</h2>`;
110168
case 3:
111-
return html`<h3>${this.renderText(heading)}</h3>`;
169+
return html`<h3 class=${customClass}>${this.renderText(heading)}</h3>`;
112170
case 4:
113-
return html`<h4>${this.renderText(heading)}</h4>`;
171+
return html`<h4 class=${customClass}>${this.renderText(heading)}</h4>`;
114172
case 5:
115-
return html`<h5>${this.renderText(heading)}</h5>`;
173+
return html`<h5 class=${customClass}>${this.renderText(heading)}</h5>`;
116174
default:
117-
return html`<h6>${this.renderText(heading)}</h6>`;
175+
return html`<h6 class=${customClass}>${this.renderText(heading)}</h6>`;
118176
}
119177
}
120178

121179
renderCodeBlock(token: Marked.Marked.Tokens.Code): LitHtml.TemplateResult {
122180
// clang-format off
123181
return html`<devtools-code-block
182+
class=${this.#customClassMapForToken('code')}
124183
.code=${this.unescape(token.text)}
125184
.codeLang=${token.lang || ''}>
126185
</devtools-code-block>`;
@@ -130,39 +189,43 @@ export class MarkdownLitRenderer {
130189
templateForToken(token: Marked.Marked.MarkedToken): LitHtml.TemplateResult|null {
131190
switch (token.type) {
132191
case 'paragraph':
133-
return html`<p>${this.renderChildTokens(token)}</p>`;
192+
return html`<p class=${this.#customClassMapForToken('paragraph')}>${this.renderChildTokens(token)}</p>`;
134193
case 'list':
135-
return html`<ul>${token.items.map(token => {
194+
return html`<ul class=${this.#customClassMapForToken('list')}>${token.items.map(token => {
136195
return this.renderToken(token);
137196
})}</ul>`;
138197
case 'list_item':
139-
return html`<li>${this.renderChildTokens(token)}</li>`;
198+
return html`<li class=${this.#customClassMapForToken('list_item')}>${this.renderChildTokens(token)}</li>`;
140199
case 'text':
141200
return this.renderText(token);
142201
case 'codespan':
143-
return html`<code>${this.unescape(token.text)}</code>`;
202+
return html`<code class=${this.#customClassMapForToken('codespan')}>${this.unescape(token.text)}</code>`;
144203
case 'code':
145204
return this.renderCodeBlock(token);
146205
case 'space':
147206
return html``;
148207
case 'link':
149-
return html`<devtools-markdown-link .data=${{
208+
return html`<devtools-markdown-link
209+
class=${this.#customClassMapForToken('link')}
210+
.data=${{
150211
key:
151212
token.href, title: token.text,
152213
}
153214
}></devtools-markdown-link>`;
154215
case 'image':
155-
return html`<devtools-markdown-image .data=${{
216+
return html`<devtools-markdown-image
217+
class=${this.#customClassMapForToken('image')}
218+
.data=${{
156219
key:
157220
token.href, title: token.text,
158221
}
159222
}></devtools-markdown-image>`;
160223
case 'heading':
161224
return this.renderHeading(token);
162225
case 'strong':
163-
return html`<strong>${this.renderText(token)}</strong>`;
226+
return html`<strong class=${this.#customClassMapForToken('strong')}>${this.renderText(token)}</strong>`;
164227
case 'em':
165-
return html`<em>${this.renderText(token)}</em>`;
228+
return html`<em class=${this.#customClassMapForToken('em')}>${this.renderText(token)}</em>`;
166229
default:
167230
return null;
168231
}

front_end/ui/components/markdown_view/markdownView.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@
88
--code-background-color: var(--sys-color-surface4);
99
}
1010

11+
@keyframes typing {
12+
from { width: 0; }
13+
to { width: 100%; }
14+
}
15+
16+
.animating {
17+
overflow: hidden;
18+
white-space: nowrap;
19+
animation: typing 0.3s steps(40, end);
20+
}
21+
22+
.pending {
23+
display: none !important; /* stylelint-disable-line declaration-no-important */
24+
}
25+
26+
1127
.message {
1228
line-height: 18px;
1329
font-size: 12px;

0 commit comments

Comments
 (0)