Skip to content

Commit 03381b3

Browse files
danilsomsikovDevtools-frontend LUCI CQ
authored andcommitted
Introduce <devtools-highlight> custom element.
This change adds a new `<devtools-highlight>` custom element that uses the `HighlightManager` to apply highlighting based on `ranges` and `current-range` attributes. The `DeveloperResourcesListView` and `XMLView` are refactored to use this new component for displaying search and filter highlights. Bug: 414630818 Change-Id: Ifb8073ecd177a2c28b70413c2712c28ad008d5d7 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/7002770 Auto-Submit: Danil Somsikov <[email protected]> Reviewed-by: Philip Pfaffe <[email protected]> Commit-Queue: Danil Somsikov <[email protected]>
1 parent 3d1bd00 commit 03381b3

File tree

8 files changed

+280
-53
lines changed

8 files changed

+280
-53
lines changed

config/gni/devtools_grd_files.gni

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2380,6 +2380,7 @@ grd_files_unbundled_sources = [
23802380
"front_end/ui/components/helpers/directives.js",
23812381
"front_end/ui/components/helpers/get-root-node.js",
23822382
"front_end/ui/components/helpers/scheduled-render.js",
2383+
"front_end/ui/components/highlighting/HighlightElement.js",
23832384
"front_end/ui/components/highlighting/HighlightManager.js",
23842385
"front_end/ui/components/icon_button/FileSourceIcon.js",
23852386
"front_end/ui/components/icon_button/Icon.js",

docs/ui_engineering.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,3 +1299,25 @@ export class NodeIndicatorProvider implements UI.Toolbar.Provider {
12991299
}
13001300
}
13011301
```
1302+
1303+
## Highlighting text
1304+
1305+
### (UI.UIUtils.highlightRangesWithStyleClass or Highlighting.HighlightManager)
1306+
1307+
Use the `<devtools-highlight>` component to highlight text ranges within its
1308+
container. The component takes two attributes: `ranges`, which is a
1309+
space-separated list of `offset,length` pairs, and `current-range`, which is a
1310+
single `offset,length` pair to highlight with a different color.
1311+
1312+
The component will automatically sort and merge the ranges provided.
1313+
1314+
```html
1315+
<div style="position:relative">
1316+
<devtools-highlight ranges="10,2 1,3 2,3" current-range="5,3">
1317+
This is some text to highlight.
1318+
</devtools-highlight>
1319+
</div>
1320+
```
1321+
1322+
In this example, the ranges `1,3` and `2,3` will be merged into `1,4`. The
1323+
ranges `10,2` and the current range `5,3` will also be highlighted.

front_end/panels/developer_resources/DeveloperResourcesListView.ts

Lines changed: 22 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,19 @@
33
// found in the LICENSE file.
44

55
import '../../ui/legacy/components/data_grid/data_grid.js';
6+
import '../../ui/components/highlighting/highlighting.js';
67

78
import * as Host from '../../core/host/host.js';
89
import * as i18n from '../../core/i18n/i18n.js';
910
import * as Platform from '../../core/platform/platform.js';
1011
import * as SDK from '../../core/sdk/sdk.js';
1112
import type * as Protocol from '../../generated/protocol.js';
12-
import * as TextUtils from '../../models/text_utils/text_utils.js';
13+
import type * as TextUtils from '../../models/text_utils/text_utils.js';
1314
import * as UI from '../../ui/legacy/legacy.js';
14-
import {Directives, html, nothing, render} from '../../ui/lit/lit.js';
15+
import {html, nothing, render} from '../../ui/lit/lit.js';
1516

1617
import developerResourcesListViewStyles from './developerResourcesListView.css.js';
1718

18-
const {ref} = Directives;
19-
2019
const UIStrings = {
2120
/**
2221
* @description Text for the status of something
@@ -87,7 +86,6 @@ const {withThousandsSeparator} = Platform.NumberUtilities;
8786
export interface ViewInput {
8887
items: SDK.PageResourceLoader.PageResource[];
8988
selectedItem: SDK.PageResourceLoader.PageResource|null;
90-
highlight: (element: Element|undefined, textContent: string|undefined, columnId: string) => void;
9189
filters: TextUtils.TextUtils.ParsedFilter[];
9290
onContextMenu: (e: CustomEvent<{menu: UI.ContextMenu.ContextMenu, element: HTMLElement}>) => void;
9391
onSelect: (e: CustomEvent<HTMLElement>) => void;
@@ -98,6 +96,20 @@ export interface ViewInput {
9896
export type View = (input: ViewInput, output: object, target: HTMLElement) => void;
9997

10098
const DEFAULT_VIEW: View = (input, _output, target) => {
99+
function highlightRange(textContent: string|undefined, columnId: string): string {
100+
if (!textContent) {
101+
return '';
102+
}
103+
const filter = input.filters.find(filter => filter.key?.split(',')?.includes(columnId));
104+
if (!filter?.regex) {
105+
return '';
106+
}
107+
const matches = filter.regex.exec(textContent ?? '');
108+
if (!matches?.length) {
109+
return '';
110+
}
111+
return `${matches.index},${matches[0].length}`;
112+
}
101113
// clang-format off
102114
render(html`
103115
<style>${developerResourcesListViewStyles}</style>
@@ -135,11 +147,11 @@ const DEFAULT_VIEW: View = (input, _output, target) => {
135147
item.success === false ? i18nString(UIStrings.failure) :
136148
i18nString(UIStrings.pending)}</td>
137149
<td title=${item.url} aria-label=${item.url}>
138-
<div aria-hidden="true" part="url-outer"
139-
${ref(e => input.highlight(e, item.url, 'url'))}>
150+
<devtools-highlight aria-hidden="true" part="url-outer"
151+
ranges=${highlightRange(item.url, 'url')}>
140152
<div part="url-prefix">${splitURL ? splitURL[1] : item.url}</div>
141153
<div part="url-suffix">${splitURL ? splitURL[2] : ''}</div>
142-
</div>
154+
</devtools-highlight>
143155
</td>
144156
<td title=${item.initiator.initiatorUrl || ''}
145157
aria-label=${item.initiator.initiatorUrl || ''}
@@ -154,9 +166,9 @@ const DEFAULT_VIEW: View = (input, _output, target) => {
154166
item.duration !== null ? html`<span>${i18n.TimeUtilities.millisToString(item.duration)}</span>` : ''}</td>
155167
<td class="error-message">
156168
${item.errorMessage ? html`
157-
<span ${ref(e => input.highlight(e, item.errorMessage, 'error-message'))}>
169+
<devtools-highlight ranges=${highlightRange(item.errorMessage, 'error-message')}>
158170
${item.errorMessage}
159-
</span>` : nothing}
171+
</devtools-highlight>` : nothing}
160172
</td>
161173
</tr>`;
162174
})}
@@ -233,7 +245,6 @@ export class DeveloperResourcesListView extends UI.Widget.VBox {
233245
items: this.#items,
234246
selectedItem: this.#selectedItem,
235247
filters: this.#filters,
236-
highlight: this.#highlight.bind(this),
237248
onContextMenu: (e: CustomEvent<{menu: UI.ContextMenu.ContextMenu, element: HTMLElement}>) => {
238249
if (e.detail?.element) {
239250
this.#populateContextMenu(e.detail.menu, e.detail.element);
@@ -256,28 +267,4 @@ export class DeveloperResourcesListView extends UI.Widget.VBox {
256267
const output = {};
257268
this.#view(input, output, this.contentElement);
258269
}
259-
260-
#highlight(element: Element|undefined, textContent: string|undefined, columnId: string): void {
261-
if (!element || !textContent) {
262-
return;
263-
}
264-
const highlightContainers =
265-
new Set<Element>([...element.querySelectorAll('.filter-highlight')].map(e => e.parentElement as Element));
266-
for (const container of highlightContainers) {
267-
container.textContent = container.textContent;
268-
}
269-
const filter = this.#filters.find(filter => filter.key?.split(',')?.includes(columnId));
270-
if (!filter?.regex) {
271-
return;
272-
}
273-
const matches = filter.regex.exec(element.textContent ?? '');
274-
if (!matches?.length) {
275-
return;
276-
}
277-
const range = new TextUtils.TextRange.SourceRange(matches.index, matches[0].length);
278-
UI.UIUtils.highlightRangesWithStyleClass(element, [range], 'filter-highlight');
279-
for (const el of element.querySelectorAll('.filter-highlight')) {
280-
el.setAttribute('part', 'filter-highlight');
281-
}
282-
}
283270
}

front_end/ui/components/highlighting/BUILD.gn

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import("../../../../scripts/build/typescript/typescript.gni")
99
import("../visibility.gni")
1010

1111
devtools_module("highlighting") {
12-
sources = [ "HighlightManager.ts" ]
12+
sources = [
13+
"HighlightElement.ts",
14+
"HighlightManager.ts",
15+
]
1316
deps = [ "../../../models/text_utils:bundle" ]
1417
}
1518

@@ -24,7 +27,13 @@ devtools_entrypoint("bundle") {
2427
ts_library("unittests") {
2528
testonly = true
2629

27-
sources = [ "HighlightManager.test.ts" ]
30+
sources = [
31+
"HighlightElement.test.ts",
32+
"HighlightManager.test.ts",
33+
]
2834

29-
deps = [ ":bundle" ]
35+
deps = [
36+
":bundle",
37+
"../../../testing",
38+
]
3039
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright 2025 The Chromium Authors
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import * as TextUtils from '../../../models/text_utils/text_utils.js';
6+
import {renderElementIntoDOM} from '../../../testing/DOMHelpers.js';
7+
8+
import * as Highlighting from './highlighting.js';
9+
10+
describe('HighlightElement', () => {
11+
let setStub: sinon.SinonStub;
12+
13+
beforeEach(() => {
14+
setStub = sinon.stub(Highlighting.HighlightManager.HighlightManager.instance({forceNew: true}), 'set');
15+
});
16+
17+
function createHighlightElement(): HTMLElement {
18+
const element = document.createElement('devtools-highlight');
19+
renderElementIntoDOM(element);
20+
return element;
21+
}
22+
23+
it('sets ranges on the highlight manager when the attribute is set', () => {
24+
const element = createHighlightElement();
25+
element.setAttribute('ranges', '1,2 3,4');
26+
27+
const expectedRanges = [
28+
new TextUtils.TextRange.SourceRange(1, 6),
29+
];
30+
sinon.assert.calledOnce(setStub);
31+
const actualCall = setStub.getCall(0);
32+
assert.strictEqual(actualCall.args[0], element);
33+
assert.deepEqual(actualCall.args[1], expectedRanges);
34+
assert.isUndefined(actualCall.args[2]);
35+
});
36+
37+
it('sets current range on the highlight manager when the attribute is set', () => {
38+
const element = createHighlightElement();
39+
element.setAttribute('current-range', '5,6');
40+
41+
const currentRange = new TextUtils.TextRange.SourceRange(5, 6);
42+
assert.isTrue(setStub.calledOnceWith(element, [], currentRange));
43+
});
44+
45+
it('updates both ranges and current range on the highlight manager', () => {
46+
const element = createHighlightElement();
47+
48+
element.setAttribute('ranges', '1,2 3,4');
49+
const expectedRanges = [
50+
new TextUtils.TextRange.SourceRange(1, 6),
51+
];
52+
sinon.assert.calledOnce(setStub);
53+
assert.deepEqual(setStub.getCall(0).args[1], expectedRanges);
54+
55+
setStub.resetHistory();
56+
57+
element.setAttribute('current-range', '5,6');
58+
const expectedCurrentRange = new TextUtils.TextRange.SourceRange(5, 6);
59+
sinon.assert.calledOnce(setStub);
60+
const actualCall = setStub.getCall(0);
61+
assert.strictEqual(actualCall.args[0], element);
62+
assert.deepEqual(actualCall.args[1], expectedRanges);
63+
assert.deepEqual(actualCall.args[2], expectedCurrentRange);
64+
});
65+
66+
it('updates both current range and ranges on the highlight manager', () => {
67+
const element = createHighlightElement();
68+
69+
element.setAttribute('current-range', '5,6');
70+
const expectedCurrentRange = new TextUtils.TextRange.SourceRange(5, 6);
71+
sinon.assert.calledOnce(setStub);
72+
assert.deepEqual(setStub.getCall(0).args[2], expectedCurrentRange);
73+
74+
setStub.resetHistory();
75+
76+
element.setAttribute('ranges', '1,2 3,4');
77+
const expectedRanges = [
78+
new TextUtils.TextRange.SourceRange(1, 6),
79+
];
80+
sinon.assert.calledOnce(setStub);
81+
const actualCall = setStub.getCall(0);
82+
assert.strictEqual(actualCall.args[0], element);
83+
assert.deepEqual(actualCall.args[1], expectedRanges);
84+
assert.deepEqual(actualCall.args[2], expectedCurrentRange);
85+
});
86+
87+
it('handles empty range attributes', () => {
88+
const element = createHighlightElement();
89+
90+
element.setAttribute('ranges', '');
91+
assert.isTrue(setStub.calledOnceWith(element, [], undefined));
92+
});
93+
94+
it('handles invalid range attributes', () => {
95+
const element = createHighlightElement();
96+
97+
element.setAttribute('ranges', 'foo bar 1,2,3 4');
98+
assert.isTrue(setStub.calledOnceWith(element, [], undefined));
99+
});
100+
101+
it('sorts and merges ranges', () => {
102+
const element = createHighlightElement();
103+
element.setAttribute('ranges', '10,2 1,3 2,3');
104+
105+
const ranges = [
106+
new TextUtils.TextRange.SourceRange(1, 4),
107+
new TextUtils.TextRange.SourceRange(10, 2),
108+
];
109+
assert.isTrue(setStub.calledOnceWith(element, ranges, undefined));
110+
});
111+
112+
it('does not call set if attribute value does not change', () => {
113+
const element = createHighlightElement();
114+
115+
element.setAttribute('ranges', '1,2');
116+
sinon.assert.calledOnce(setStub);
117+
118+
setStub.resetHistory();
119+
120+
element.setAttribute('ranges', '1,2');
121+
sinon.assert.notCalled(setStub);
122+
});
123+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright 2025 The Chromium Authors
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import * as TextUtils from '../../../models/text_utils/text_utils.js';
6+
7+
import {HighlightManager} from './HighlightManager.js';
8+
9+
export class HighlightElement extends HTMLElement {
10+
static readonly observedAttributes = ['ranges', 'current-range'];
11+
#ranges: TextUtils.TextRange.SourceRange[] = [];
12+
#currentRange: TextUtils.TextRange.SourceRange|undefined;
13+
14+
attributeChangedCallback(name: string, oldValue: string|null, newValue: string|null): void {
15+
if (oldValue === newValue) {
16+
return;
17+
}
18+
switch (name) {
19+
case 'ranges':
20+
this.#ranges = parseRanges(newValue);
21+
break;
22+
case 'current-range':
23+
this.#currentRange = parseRanges(newValue)[0];
24+
break;
25+
}
26+
HighlightManager.instance().set(this, this.#ranges, this.#currentRange);
27+
}
28+
}
29+
30+
function parseRanges(value: string|null): TextUtils.TextRange.SourceRange[] {
31+
if (!value) {
32+
return [];
33+
}
34+
const ranges = value.split(' ')
35+
.filter(rangeString => {
36+
const parts = rangeString.split(',');
37+
// A valid range string must have exactly two parts.
38+
if (parts.length !== 2) {
39+
return false;
40+
}
41+
// Both parts must be convertible to valid numbers.
42+
const num1 = Number(parts[0]);
43+
const num2 = Number(parts[1]);
44+
return !isNaN(num1) && !isNaN(num2);
45+
})
46+
.map(rangeString => {
47+
const parts = rangeString.split(',').map(part => Number(part));
48+
return new TextUtils.TextRange.SourceRange(parts[0], parts[1]);
49+
});
50+
return sortAndMergeRanges(ranges);
51+
}
52+
53+
function sortAndMergeRanges(ranges: TextUtils.TextRange.SourceRange[]): TextUtils.TextRange.SourceRange[] {
54+
// Sort by start position.
55+
ranges.sort((a, b) => a.offset - b.offset);
56+
57+
if (ranges.length === 0) {
58+
return [];
59+
}
60+
61+
// Merge overlapping ranges.
62+
const merged = [ranges[0]];
63+
for (let i = 1; i < ranges.length; i++) {
64+
const last = merged[merged.length - 1];
65+
const current = ranges[i];
66+
if (current.offset <= last.offset + last.length) {
67+
const newEnd = Math.max(last.offset + last.length, current.offset + current.length);
68+
const newLength = newEnd - last.offset;
69+
merged[merged.length - 1] = new TextUtils.TextRange.SourceRange(last.offset, newLength);
70+
} else {
71+
merged.push(current);
72+
}
73+
}
74+
return merged;
75+
}
76+
77+
customElements.define('devtools-highlight', HighlightElement);

front_end/ui/components/highlighting/highlighting.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
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 './HighlightElement.js';
6+
57
import * as HighlightManager from './HighlightManager.js';
68

79
export {HighlightManager};

0 commit comments

Comments
 (0)