Skip to content

Commit 8483ee7

Browse files
pfaffeDevtools-frontend LUCI CQ
authored andcommitted
[css value tracing] Implement vertical highlights for expressions
Hovering a sub-expression in a trace line will highlight the same expression in other lines to simplify identifying value progression through the trace. Fixed: 401482931 Change-Id: Ie15ea1e5541caada9414b22fdd7a5d262a0252bf Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6333519 Reviewed-by: Eric Leese <[email protected]> Commit-Queue: Philip Pfaffe <[email protected]>
1 parent d526dcb commit 8483ee7

File tree

7 files changed

+221
-23
lines changed

7 files changed

+221
-23
lines changed

front_end/core/sdk/CSSPropertyParserMatchers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,9 @@ export class TextMatch implements Match {
165165
}
166166
}
167167
render(): Node[] {
168-
return [document.createTextNode(this.text)];
168+
const span = document.createElement('span');
169+
span.appendChild(document.createTextNode(this.text));
170+
return [span];
169171
}
170172
}
171173

front_end/panels/elements/CSSValueTraceView.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as UI from '../../ui/legacy/legacy.js';
88

99
import cssValueTraceViewStyles from './cssValueTraceView.css.js';
1010
import {
11+
Highlighting,
1112
type MatchRenderer,
1213
Renderer,
1314
RenderingContext,
@@ -75,7 +76,8 @@ function defaultView(input: ViewInput, _: unknown, target: HTMLElement): void {
7576
}
7677

7778
export class CSSValueTraceView extends UI.Widget.VBox {
78-
#view: View;
79+
#highlighting: Highlighting|undefined;
80+
readonly #view: View;
7981
#finalResult: Node[]|undefined = undefined;
8082
#evaluations: Node[][] = [];
8183
#substitutions: Node[][] = [];
@@ -113,13 +115,14 @@ export class CSSValueTraceView extends UI.Widget.VBox {
113115
computedStyles: Map<string, string>|null,
114116
renderers: Array<MatchRenderer<SDK.CSSPropertyParser.Match>>,
115117
): void {
118+
this.#highlighting = new Highlighting();
116119
const rendererMap = new Map(renderers.map(r => [r.matchType, r]));
117120

118121
// Compute all trace lines
119122
// 1st: Apply substitutions for var() functions
120123
const substitutions = [];
121124
const evaluations = [];
122-
const tracing = new TracingContext(matchedResult);
125+
const tracing = new TracingContext(this.#highlighting, matchedResult);
123126
while (tracing.nextSubstitution()) {
124127
const context = new RenderingContext(
125128
matchedResult.ast,

front_end/panels/elements/PropertyRenderer.test.ts

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// found in the LICENSE file.
44

55
import * as SDK from '../../core/sdk/sdk.js';
6+
import {renderElementIntoDOM} from '../../testing/DOMHelpers.js';
67
import {describeWithEnvironment} from '../../testing/EnvironmentHelpers.js';
78
import {Printer} from '../../testing/PropertyParser.js';
89
import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
@@ -100,21 +101,24 @@ describe('TracingContext', () => {
100101
it('assumes no substitutions by default', () => {
101102
const matchedResult = sinon.createStubInstance(SDK.CSSPropertyParser.BottomUpTreeMatching);
102103
matchedResult.hasMatches.returns(false);
103-
const context = new Elements.PropertyRenderer.TracingContext(matchedResult);
104+
const context =
105+
new Elements.PropertyRenderer.TracingContext(new Elements.PropertyRenderer.Highlighting(), matchedResult);
104106
assert.isFalse(context.nextSubstitution());
105107

106108
matchedResult.hasMatches.returns(true);
107-
const context2 = new Elements.PropertyRenderer.TracingContext(matchedResult);
109+
const context2 =
110+
new Elements.PropertyRenderer.TracingContext(new Elements.PropertyRenderer.Highlighting(), matchedResult);
108111
assert.isTrue(context2.nextSubstitution());
109112

110-
const context3 = new Elements.PropertyRenderer.TracingContext();
113+
const context3 = new Elements.PropertyRenderer.TracingContext(new Elements.PropertyRenderer.Highlighting());
111114
assert.isFalse(context3.nextSubstitution());
112115
});
113116

114117
it('controls substitution by creating "nested" tracing contexts', () => {
115118
const matchedResult = sinon.createStubInstance(SDK.CSSPropertyParser.BottomUpTreeMatching);
116119
matchedResult.hasMatches.returns(true);
117-
const context = new Elements.PropertyRenderer.TracingContext(matchedResult);
120+
const context =
121+
new Elements.PropertyRenderer.TracingContext(new Elements.PropertyRenderer.Highlighting(), matchedResult);
118122

119123
assert.isTrue(context.nextSubstitution());
120124
assert.exists(context.substitution());
@@ -140,7 +144,8 @@ describe('TracingContext', () => {
140144
it('does not allow tracing evaluations until substitutions are exhausted', () => {
141145
const matchedResult = sinon.createStubInstance(SDK.CSSPropertyParser.BottomUpTreeMatching);
142146
matchedResult.hasMatches.returns(true);
143-
const context = new Elements.PropertyRenderer.TracingContext(matchedResult);
147+
const context =
148+
new Elements.PropertyRenderer.TracingContext(new Elements.PropertyRenderer.Highlighting(), matchedResult);
144149

145150
assert.throw(() => context.nextEvaluation());
146151
context.nextSubstitution();
@@ -150,7 +155,8 @@ describe('TracingContext', () => {
150155
it('controls evaluations creating nested context', () => {
151156
const matchedResult = sinon.createStubInstance(SDK.CSSPropertyParser.BottomUpTreeMatching);
152157
matchedResult.hasMatches.returns(false);
153-
const context = new Elements.PropertyRenderer.TracingContext(matchedResult);
158+
const context =
159+
new Elements.PropertyRenderer.TracingContext(new Elements.PropertyRenderer.Highlighting(), matchedResult);
154160

155161
// Evaluations are applied bottom up
156162
assert.isTrue(context.nextEvaluation());
@@ -214,8 +220,76 @@ describe('TracingContext', () => {
214220
});
215221

216222
it('can inject itself into a RenderingContext', () => {
217-
const tracingContext = new Elements.PropertyRenderer.TracingContext();
223+
const tracingContext = new Elements.PropertyRenderer.TracingContext(new Elements.PropertyRenderer.Highlighting());
218224
const renderingContext = sinon.createStubInstance(Elements.PropertyRenderer.RenderingContext);
219225
assert.strictEqual(tracingContext.renderingContext(renderingContext).tracing, tracingContext);
220226
});
221227
});
228+
229+
describe('Highlighting', () => {
230+
const node = (id: string) => {
231+
const span = document.createElement('span');
232+
span.textContent = id;
233+
span.id = `node-${id}`;
234+
renderElementIntoDOM(span, {allowMultipleChildren: true});
235+
return span;
236+
};
237+
238+
beforeEach(() => {
239+
const highlighting = new Elements.PropertyRenderer.Highlighting();
240+
const match1 = sinon.createStubInstance(SDK.CSSPropertyParserMatchers.TextMatch);
241+
const match2 = sinon.createStubInstance(SDK.CSSPropertyParserMatchers.TextMatch);
242+
highlighting.addMatch(match1, [node('1'), node('2'), node('3')]);
243+
highlighting.addMatch(match1, [node('4'), node('5'), node('6'), node('7')]);
244+
highlighting.addMatch(match1, [node('8')]);
245+
highlighting.addMatch(match2, [node('a'), node('b'), node('c')]);
246+
});
247+
248+
it('adds highlights on mouseenter', () => {
249+
const registry = CSS.highlights.get(Elements.PropertyRenderer.Highlighting.REGISTRY_NAME);
250+
assert.exists(registry);
251+
252+
document.querySelector('#node-6')?.dispatchEvent(new MouseEvent('mouseenter'));
253+
assert.deepEqual(
254+
Array.from(registry.keys().map(value => (value as Range).cloneContents().textContent)), ['123', '4567', '8']);
255+
});
256+
257+
it('removes highlights on mouseexit', () => {
258+
const registry = CSS.highlights.get(Elements.PropertyRenderer.Highlighting.REGISTRY_NAME);
259+
assert.exists(registry);
260+
261+
document.querySelector('#node-6')?.dispatchEvent(new MouseEvent('mouseenter'));
262+
assert.deepEqual(
263+
Array.from(registry.keys().map(value => (value as Range).cloneContents().textContent)), ['123', '4567', '8']);
264+
document.querySelector('#node-6')?.dispatchEvent(new MouseEvent('mouseleave'));
265+
assert.deepEqual(Array.from(registry.keys().map(value => (value as Range).cloneContents().textContent)), []);
266+
});
267+
268+
it('replaces highlights on subsequent mouseenter', () => {
269+
const registry = CSS.highlights.get(Elements.PropertyRenderer.Highlighting.REGISTRY_NAME);
270+
assert.exists(registry);
271+
272+
document.querySelector('#node-6')?.dispatchEvent(new MouseEvent('mouseenter'));
273+
assert.deepEqual(
274+
Array.from(registry.keys().map(value => (value as Range).cloneContents().textContent)), ['123', '4567', '8']);
275+
276+
document.querySelector('#node-a')?.dispatchEvent(new MouseEvent('mouseenter'));
277+
assert.deepEqual(Array.from(registry.keys().map(value => (value as Range).cloneContents().textContent)), ['abc']);
278+
});
279+
280+
it('restores previous highlights on mouseexit', () => {
281+
const registry = CSS.highlights.get(Elements.PropertyRenderer.Highlighting.REGISTRY_NAME);
282+
assert.exists(registry);
283+
284+
document.querySelector('#node-6')?.dispatchEvent(new MouseEvent('mouseenter'));
285+
assert.deepEqual(
286+
Array.from(registry.keys().map(value => (value as Range).cloneContents().textContent)), ['123', '4567', '8']);
287+
288+
document.querySelector('#node-a')?.dispatchEvent(new MouseEvent('mouseenter'));
289+
assert.deepEqual(Array.from(registry.keys().map(value => (value as Range).cloneContents().textContent)), ['abc']);
290+
291+
document.querySelector('#node-a')?.dispatchEvent(new MouseEvent('mouseleave'));
292+
assert.deepEqual(
293+
Array.from(registry.keys().map(value => (value as Range).cloneContents().textContent)), ['123', '4567', '8']);
294+
});
295+
});

front_end/panels/elements/PropertyRenderer.ts

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,87 @@ export function rendererBase<MatchT extends SDK.CSSPropertyParser.Match>(
5656
return RendererBase;
5757
}
5858

59+
// This class implements highlighting for rendered nodes in value traces. On hover, all nodes belonging to the same
60+
// Match (using object identity) are highlighted.
61+
export class Highlighting {
62+
static readonly REGISTRY_NAME = 'css-value-tracing';
63+
// This holds a stack of active ranges, the top-stack is the currently highlighted set. mouseenter and mouseleave
64+
// push and pop range sets, respectively.
65+
readonly #activeHighlights: Range[][] = [];
66+
// We hold a bidirectional mapping between nodes and matches. A node can belong to multiple matches when matches are
67+
// nested (via function arguments for instance).
68+
readonly #nodesForMatches = new Map<SDK.CSSPropertyParser.Match, Node[][]>();
69+
readonly #matchesForNodes = new Map<Node, SDK.CSSPropertyParser.Match[]>();
70+
readonly #registry: Highlight;
71+
readonly #boundOnEnter: (ev: MouseEvent) => void;
72+
readonly #boundOnExit: (ev: MouseEvent) => void;
73+
74+
constructor() {
75+
const registry = CSS.highlights.get(Highlighting.REGISTRY_NAME);
76+
this.#registry = registry ?? new Highlight();
77+
if (!registry) {
78+
CSS.highlights.set(Highlighting.REGISTRY_NAME, this.#registry);
79+
}
80+
this.#boundOnExit = this.#onExit.bind(this);
81+
this.#boundOnEnter = this.#onEnter.bind(this);
82+
}
83+
84+
addMatch(match: SDK.CSSPropertyParser.Match, nodes: Node[]): void {
85+
if (nodes.length > 0) {
86+
const ranges = this.#nodesForMatches.get(match);
87+
if (ranges) {
88+
ranges.push(nodes);
89+
} else {
90+
this.#nodesForMatches.set(match, [nodes]);
91+
}
92+
}
93+
for (const node of nodes) {
94+
const matches = this.#matchesForNodes.get(node);
95+
if (matches) {
96+
matches.push(match);
97+
} else {
98+
this.#matchesForNodes.set(node, [match]);
99+
}
100+
if (node instanceof HTMLElement) {
101+
node.onmouseenter = this.#boundOnEnter;
102+
node.onmouseleave = this.#boundOnExit;
103+
}
104+
}
105+
}
106+
107+
* #nodeRangesHitByMouseEvent(e: MouseEvent): Generator<Node[]> {
108+
for (const node of e.composedPath()) {
109+
const matches = this.#matchesForNodes.get(node as Node);
110+
if (matches) {
111+
for (const match of matches) {
112+
yield* this.#nodesForMatches.get(match) ?? [];
113+
}
114+
break;
115+
}
116+
}
117+
}
118+
119+
#onEnter(e: MouseEvent): void {
120+
this.#registry.clear();
121+
this.#activeHighlights.push([]);
122+
for (const nodeRange of this.#nodeRangesHitByMouseEvent(e)) {
123+
const range = new Range();
124+
range.setStartBefore(nodeRange[0]);
125+
range.setEndAfter(nodeRange[nodeRange.length - 1]);
126+
this.#activeHighlights[this.#activeHighlights.length - 1].push(range);
127+
this.#registry.add(range);
128+
}
129+
}
130+
131+
#onExit(): void {
132+
this.#registry.clear();
133+
this.#activeHighlights.pop();
134+
if (this.#activeHighlights.length > 0) {
135+
this.#activeHighlights[this.#activeHighlights.length - 1].forEach(range => this.#registry.add(range));
136+
}
137+
}
138+
}
139+
59140
// This class is used to guide value tracing when passed to the Renderer. Tracing has two phases. First, substitutions
60141
// such as var() are applied step by step. In each step, all vars in the value are replaced by their definition until no
61142
// vars remain. In the second phase, we evaluate other functions such as calc() or min() or color-mix(). Which CSS
@@ -76,14 +157,25 @@ export class TracingContext {
76157
#evaluationCount = 0;
77158
#appliedEvaluations = 0;
78159
#hasMoreEvaluations = true;
79-
80-
constructor(matchedResult?: SDK.CSSPropertyParser.BottomUpTreeMatching) {
160+
readonly #highlighting: Highlighting;
161+
#parsedValueCache = new Map<SDK.CSSProperty.CSSProperty, {
162+
matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles,
163+
computedStyles: Map<string, string>,
164+
parsedValue: SDK.CSSPropertyParser.BottomUpTreeMatching|null,
165+
}>();
166+
167+
constructor(highlighting: Highlighting, matchedResult?: SDK.CSSPropertyParser.BottomUpTreeMatching) {
168+
this.#highlighting = highlighting;
81169
this.#hasMoreSubstitutions =
82170
matchedResult?.hasMatches(
83171
SDK.CSSPropertyParserMatchers.VariableMatch, SDK.CSSPropertyParserMatchers.BaseVariableMatch) ??
84172
false;
85173
}
86174

175+
get highlighting(): Highlighting {
176+
return this.#highlighting;
177+
}
178+
87179
renderingContext(context: RenderingContext): RenderingContext {
88180
return new RenderingContext(
89181
context.ast, context.property, context.renderers, context.matchedResult, context.cssControls, context.options,
@@ -128,11 +220,12 @@ export class TracingContext {
128220
// be passed to the Renderer calls for the respective subtrees.
129221
evaluation(args: unknown[]): TracingContext[]|null {
130222
const childContexts = args.map(() => {
131-
const child = new TracingContext();
223+
const child = new TracingContext(this.#highlighting);
132224
child.#parent = this;
133225
child.#substitutionDepth = this.#substitutionDepth;
134226
child.#evaluationCount = this.#evaluationCount;
135227
child.#hasMoreSubstitutions = this.#hasMoreSubstitutions;
228+
child.#parsedValueCache = this.#parsedValueCache;
136229
return child;
137230
});
138231
return childContexts;
@@ -172,13 +265,26 @@ export class TracingContext {
172265
this.#setHasMoreSubstitutions();
173266
return null;
174267
}
175-
const child = new TracingContext();
268+
const child = new TracingContext(this.#highlighting);
176269
child.#parent = this;
177270
child.#substitutionDepth = this.#substitutionDepth - 1;
178271
child.#evaluationCount = this.#evaluationCount;
179272
child.#hasMoreSubstitutions = false;
273+
child.#parsedValueCache = this.#parsedValueCache;
180274
return child;
181275
}
276+
277+
cachedParsedValue(
278+
declaration: SDK.CSSProperty.CSSProperty, matchedStyles: SDK.CSSMatchedStyles.CSSMatchedStyles,
279+
computedStyles: Map<string, string>): SDK.CSSPropertyParser.BottomUpTreeMatching|null {
280+
const cachedValue = this.#parsedValueCache.get(declaration);
281+
if (cachedValue?.matchedStyles === matchedStyles && cachedValue?.computedStyles === computedStyles) {
282+
return cachedValue.parsedValue;
283+
}
284+
const parsedValue = declaration.parseValue(matchedStyles, computedStyles);
285+
this.#parsedValueCache.set(declaration, {matchedStyles, computedStyles, parsedValue});
286+
return parsedValue;
287+
}
182288
}
183289

184290
export class RenderingContext {
@@ -263,6 +369,7 @@ export class Renderer extends SDK.CSSPropertyParser.TreeWalker {
263369
if (renderer || match instanceof SDK.CSSPropertyParserMatchers.TextMatch) {
264370
const output = renderer ? renderer.render(match, this.#context) :
265371
(match as SDK.CSSPropertyParserMatchers.TextMatch).render();
372+
this.#context.tracing?.highlighting.addMatch(match, output);
266373
this.renderedMatchForTest(output, match);
267374
this.#output = mergeWithSpacing(this.#output, output);
268375
return false;

front_end/panels/elements/StylePropertyTreeElement.test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ describeWithMockConnection('StylePropertyTreeElement', () => {
281281
({results: request.values.map(v => v === property.value ? 'grey' : v)}));
282282
const matchedResult = property.parseValue(matchedStyles, new Map());
283283

284-
const context = new Elements.PropertyRenderer.TracingContext();
284+
const context = new Elements.PropertyRenderer.TracingContext(new Elements.PropertyRenderer.Highlighting());
285285
assert.isTrue(context.nextEvaluation());
286286
const {valueElement} = Elements.PropertyRenderer.Renderer.renderValueElement(
287287
property, matchedResult,
@@ -1631,7 +1631,8 @@ describeWithMockConnection('StylePropertyTreeElement', () => {
16311631
const stylePropertyTreeElement =
16321632
getTreeElement('position-try', '/* comment */ most-height --top, --left, --bottom');
16331633
stylePropertyTreeElement.updateTitle();
1634-
const values = stylePropertyTreeElement.valueElement?.querySelectorAll(':scope > span');
1634+
const values =
1635+
stylePropertyTreeElement.valueElement?.querySelectorAll(':scope > span:has(> devtools-link-swatch)');
16351636
assert.exists(values);
16361637
assert.strictEqual(values?.length, 3);
16371638
assert.isTrue(values[0].classList.contains('inactive-value'));
@@ -1709,17 +1710,19 @@ describeWithMockConnection('StylePropertyTreeElement', () => {
17091710
stylePropertyTreeElement.updateTitle();
17101711

17111712
let args = stylePropertyTreeElement.valueElement?.querySelectorAll('span') as NodeListOf<HTMLSpanElement>;
1712-
assert.lengthOf(args, 3);
1713+
assert.lengthOf(args, 5);
17131714
assert.deepEqual(
1714-
Array.from(args.values()).map(arg => arg.classList.contains('inactive-value')), [false, false, true]);
1715+
Array.from(args.values()).map(arg => arg.classList.contains('inactive-value')),
1716+
[false, false, false, true, false]);
17151717

17161718
stylePropertyTreeElement.setComputedStyles(new Map([['appearance', 'base-select']]));
17171719
stylePropertyTreeElement.updateTitle();
17181720

17191721
args = stylePropertyTreeElement.valueElement?.querySelectorAll('span') as NodeListOf<HTMLSpanElement>;
1720-
assert.lengthOf(args, 3);
1722+
assert.lengthOf(args, 5);
17211723
assert.deepEqual(
1722-
Array.from(args.values()).map(arg => arg.classList.contains('inactive-value')), [false, true, false]);
1724+
Array.from(args.values()).map(arg => arg.classList.contains('inactive-value')),
1725+
[false, true, false, false, false]);
17231726
});
17241727
});
17251728

0 commit comments

Comments
 (0)