Skip to content
This repository was archived by the owner on Jul 9, 2025. It is now read-only.

Commit 8c4decd

Browse files
committed
Bug 1440229 - [devtools] Scroll to first search result matching Range in Markup view. r=devtools-reviewers,jdescottes.
When navigating through search results, we were selecting nodes and scrolling them into view, centered vertically. This is not working well in some cases: - if the node itself is taller than the markup view, we're scrolling in the middle of the node and might miss the actual search results - if the node is wider than the markup view, since we're not scrolling horizontally, we might miss a search result at the very right of the node To fix those issue, we're taking advantage of `nsISelectionController.scrollSelectionIntoView`, putting the first search result Range in the selection so we scroll to the right location. It's okay to put the Range in the selection since the user can't have any text selected at this point. A couple test cases are added to cover this. Differential Revision: https://phabricator.services.mozilla.com/D256082
1 parent 14b2ff3 commit 8c4decd

File tree

3 files changed

+247
-14
lines changed

3 files changed

+247
-14
lines changed

devtools/client/inspector/markup/markup.js

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,7 +1077,18 @@ MarkupView.prototype = {
10771077

10781078
const slotted = selection.isSlotted();
10791079
const smoothScroll = reason === "reveal-from-slot";
1080-
const onShow = this.showNode(selection.nodeFront, { slotted, smoothScroll })
1080+
const selectionSearchQuery = selection.getSearchQuery();
1081+
1082+
const onShow = this.showNode(selection.nodeFront, {
1083+
slotted,
1084+
smoothScroll,
1085+
// Don't scroll if we selected the node from the search, we'll scroll to the first
1086+
// matching Range done in _updateSearchResultsHighlightingInSelectedNode.
1087+
// This need to be done there because the matching Range might be out of screen,
1088+
// for example if the node is very tall, or if the markup view overflows horizontally
1089+
// and the Range is located near the right end of the node container.
1090+
scroll: !selectionSearchQuery,
1091+
})
10811092
.then(() => {
10821093
// We could be destroyed by now.
10831094
if (this._destroyed) {
@@ -1088,7 +1099,7 @@ MarkupView.prototype = {
10881099
const container = this.getContainer(selection.nodeFront, slotted);
10891100
this._markContainerAsSelected(container);
10901101
this._updateSearchResultsHighlightingInSelectedNode(
1091-
selection.getSearchQuery()
1102+
selectionSearchQuery
10921103
);
10931104

10941105
// Make sure the new selection is navigated to.
@@ -1111,6 +1122,20 @@ MarkupView.prototype = {
11111122
return highlights.get(highlightName);
11121123
},
11131124

1125+
/**
1126+
* @returns {nsISelectionController}
1127+
*/
1128+
_getSelectionController() {
1129+
if (!this._selectionController) {
1130+
// QueryInterface can be expensive, so cache the controller.
1131+
this._selectionController = this.win.docShell
1132+
.QueryInterface(Ci.nsIInterfaceRequestor)
1133+
.getInterface(Ci.nsISelectionDisplay)
1134+
.QueryInterface(Ci.nsISelectionController);
1135+
}
1136+
return this._selectionController;
1137+
},
1138+
11141139
/**
11151140
* Highlight search results in the markup view.
11161141
*
@@ -1136,6 +1161,8 @@ MarkupView.prototype = {
11361161
searchQuery = searchQuery.toLowerCase();
11371162
const searchQueryLength = searchQuery.length;
11381163
let currentNode = treeWalker.nextNode();
1164+
let scrolled = false;
1165+
11391166
while (currentNode) {
11401167
const text = currentNode.textContent.toLowerCase();
11411168
let startPos = 0;
@@ -1152,10 +1179,47 @@ MarkupView.prototype = {
11521179
searchHighlight.add(range);
11531180

11541181
startPos = index + searchQuery.length;
1182+
1183+
// We want to scroll the first matching range into view
1184+
if (!scrolled) {
1185+
// We want to take advantage of nsISelectionController.scrollSelectionIntoView,
1186+
// so we need to put the range in the selection. That's fine to do here because
1187+
// in this situation the user shouldn't have any text selected
1188+
const selection = this.win.getSelection();
1189+
selection.removeAllRanges();
1190+
selection.addRange(range);
1191+
1192+
const selectionController = this._getSelectionController();
1193+
selectionController.scrollSelectionIntoView(
1194+
selectionController.SELECTION_NORMAL,
1195+
selectionController.SELECTION_ON,
1196+
selectionController.SCROLL_SYNCHRONOUS |
1197+
selectionController.SCROLL_VERTICAL_CENTER
1198+
);
1199+
selection.removeAllRanges();
1200+
scrolled = true;
1201+
}
11551202
}
11561203

11571204
currentNode = treeWalker.nextNode();
11581205
}
1206+
1207+
// It can happen that we didn't find a Range for a search result (e.g. if the matching
1208+
// string is in a cropped attribute). In such case, go back to scroll the container
1209+
// into view.
1210+
if (!scrolled) {
1211+
const container = this.getContainer(
1212+
this.inspector.selection.nodeFront,
1213+
this.inspector.selection.isSlotted()
1214+
);
1215+
scrollIntoViewIfNeeded(
1216+
container.editor.elt,
1217+
// centered
1218+
true,
1219+
// smoothScroll
1220+
false
1221+
);
1222+
}
11591223
},
11601224

11611225
/**
@@ -1770,23 +1834,38 @@ MarkupView.prototype = {
17701834
/**
17711835
* Make sure the given node's parents are expanded and the
17721836
* node is scrolled on to screen.
1773-
*/
1774-
showNode(node, { centered = true, slotted, smoothScroll = false } = {}) {
1775-
if (slotted && !this.hasContainer(node, slotted)) {
1837+
*
1838+
* @param {NodeFront} nodeFront
1839+
* @param {Object} options
1840+
* @param {Boolean} options.centered
1841+
* @param {Boolean} options.scroll
1842+
* @param {Boolean} options.slotted
1843+
* @param {Boolean} options.smoothScroll
1844+
* @returns
1845+
*/
1846+
showNode(
1847+
nodeFront,
1848+
{ centered = true, scroll = true, slotted, smoothScroll = false } = {}
1849+
) {
1850+
if (slotted && !this.hasContainer(nodeFront, slotted)) {
17761851
throw new Error("Tried to show a slotted node not previously imported");
17771852
} else {
1778-
this._ensureNodeImported(node);
1853+
this._ensureNodeImported(nodeFront);
17791854
}
17801855

17811856
return this._waitForChildren()
17821857
.then(() => {
17831858
if (this._destroyed) {
17841859
return Promise.reject("markupview destroyed");
17851860
}
1786-
return this._ensureVisible(node);
1861+
return this._ensureVisible(nodeFront);
17871862
})
17881863
.then(() => {
1789-
const container = this.getContainer(node, slotted);
1864+
if (!scroll) {
1865+
return;
1866+
}
1867+
1868+
const container = this.getContainer(nodeFront, slotted);
17901869
scrollIntoViewIfNeeded(container.editor.elt, centered, smoothScroll);
17911870
}, this._handleRejectionIfNotDestroyed);
17921871
},
@@ -2642,6 +2721,7 @@ MarkupView.prototype = {
26422721
this._elt.innerHTML = "";
26432722
this._elt = null;
26442723

2724+
this._selectionController = null;
26452725
this.controllerWindow = null;
26462726
this.doc = null;
26472727
this.highlighters = null;

devtools/client/inspector/markup/test/browser_markup_search_01.js

Lines changed: 134 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,99 @@ add_task(async function () {
121121
await onSearchCleared;
122122

123123
checkHighlightedSearchResults(inspector, []);
124+
125+
await searchFor("TALLTOPMATCH", inspector);
126+
const talltopNodeFront = await getNodeFront("section.talltop", inspector);
127+
const talltopNodeFrontChildren =
128+
await inspector.walker.children(talltopNodeFront);
129+
is(
130+
inspector.selection.nodeFront,
131+
talltopNodeFrontChildren.nodes[0],
132+
`The section.talltop text node is selected`
133+
);
134+
checkHighlightedSearchResults(inspector, ["TALLTOPMATCH"]);
135+
136+
await searchFor("TALLBOTTOMMATCH", inspector);
137+
const tallbottomNodeFront = await getNodeFront(
138+
"section.tallbottom",
139+
inspector
140+
);
141+
const tallbottomNodeFrontChildren =
142+
await inspector.walker.children(tallbottomNodeFront);
143+
is(
144+
inspector.selection.nodeFront,
145+
tallbottomNodeFrontChildren.nodes[0],
146+
`The section.tallbottom text node is selected`
147+
);
148+
checkHighlightedSearchResults(inspector, ["TALLBOTTOMMATCH"]);
149+
150+
await searchFor("OVERFLOWSMATCH", inspector);
151+
const overflowsNodeFront = await getNodeFront("section.overflows", inspector);
152+
const overflowsNodeFrontChildren =
153+
await inspector.walker.children(overflowsNodeFront);
154+
is(
155+
inspector.selection.nodeFront,
156+
overflowsNodeFrontChildren.nodes[0],
157+
"The section.overflows text node is selected"
158+
);
159+
checkHighlightedSearchResults(inspector, ["OVERFLOWSMATCH"]);
160+
161+
info(
162+
"Check that matching node with non-visible search result are still being scrolled to"
163+
);
164+
// Scroll to top to make sure the node isn't in view at first
165+
const markupViewContainer = inspector.markup.win.document.documentElement;
166+
markupViewContainer.scrollTop = 0;
167+
markupViewContainer.scrollLeft = 0;
168+
169+
const croppedAttributeContainer = await getContainerForSelector(
170+
"section#cropped-attribute",
171+
inspector
172+
);
173+
let croppedAttributeContainerRect =
174+
croppedAttributeContainer.elt.getBoundingClientRect();
175+
176+
ok(
177+
croppedAttributeContainerRect.y < 0 ||
178+
croppedAttributeContainerRect.y > markupViewContainer.clientHeight,
179+
"section#cropped-attribute container is not into view before searching for a match in its attributes"
180+
);
181+
182+
await searchFor("croppedvalue", inspector);
183+
is(
184+
inspector.selection.nodeFront,
185+
await getNodeFront("section#cropped-attribute", inspector),
186+
"The section#cropped-attribute element is selected"
187+
);
188+
checkHighlightedSearchResults(inspector, []);
189+
// Check that node visible after it was selected
190+
croppedAttributeContainerRect =
191+
croppedAttributeContainer.elt.getBoundingClientRect();
192+
193+
Assert.greaterOrEqual(
194+
croppedAttributeContainerRect.y,
195+
0,
196+
`Node with cropped attributes is not above visible viewport`
197+
);
198+
Assert.less(
199+
croppedAttributeContainerRect.y,
200+
markupViewContainer.clientHeight,
201+
`Node with cropped attributes is not below visible viewport`
202+
);
203+
204+
// Sanity check to make sure the markup view does overflow in both axes. We need to
205+
// wait after the search is done as their text node is only revealed when cycling through
206+
// search results.
207+
Assert.greater(
208+
markupViewContainer.scrollHeight,
209+
markupViewContainer.clientHeight,
210+
"Markup view overflows vertically"
211+
);
212+
Assert.greater(
213+
markupViewContainer.scrollWidth,
214+
markupViewContainer.clientWidth,
215+
"Markup view overflows horizontally"
216+
);
124217
});
125218

126219
async function searchFor(selector, inspector) {
@@ -133,12 +226,47 @@ async function searchFor(selector, inspector) {
133226
}
134227

135228
function checkHighlightedSearchResults(inspector, expectedHighlights) {
229+
const searchInputValue = getSelectorSearchBox(inspector).value;
230+
231+
info(`Checking highlights for "${searchInputValue}" search`);
232+
const devtoolsHighlights = [
233+
...inspector.markup.win.CSS.highlights
234+
.get(DEVTOOLS_SEARCH_HIGHLIGHT_NAME)
235+
.values(),
236+
];
136237
Assert.deepEqual(
137-
[
138-
...inspector.markup.win.CSS.highlights
139-
.get(DEVTOOLS_SEARCH_HIGHLIGHT_NAME)
140-
.values(),
141-
].map(range => range.toString()),
142-
expectedHighlights
238+
devtoolsHighlights.map(range => range.toString()),
239+
expectedHighlights,
240+
`Got expected highlights for "${searchInputValue}"`
143241
);
242+
243+
if (expectedHighlights.length) {
244+
const markupViewContainer = inspector.markup.win.document.documentElement;
245+
info(
246+
`Check that we scrolled so the first highlighted range for "${searchInputValue}" is visible`
247+
);
248+
const [rect] = devtoolsHighlights[0].getClientRects();
249+
const { x, y } = rect;
250+
251+
Assert.greaterOrEqual(
252+
y,
253+
0,
254+
`First "${searchInputValue}" match not above visible viewport`
255+
);
256+
Assert.less(
257+
y,
258+
markupViewContainer.clientHeight,
259+
`First "${searchInputValue}" match not below visible viewport`
260+
);
261+
Assert.greaterOrEqual(
262+
x,
263+
0,
264+
`First "${searchInputValue}" match not before the "left border" of the visible viewport`
265+
);
266+
Assert.less(
267+
x,
268+
markupViewContainer.clientWidth,
269+
`First "${searchInputValue}" match not after the "right border" of the visible viewport`
270+
);
271+
}
144272
}

devtools/client/inspector/markup/test/doc_markup_search.html

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,30 @@
1111
<button type="button" class="Button">OK</button>
1212
<p>Click the button</p>
1313
</section>
14+
<section class="talltop"></section>
15+
<section class="tallbottom"></section>
16+
<section class="overflows">
17+
thisisaverylongtextnodesowegetthemarkupviewtooverflowhorizontallythisisaverylongtextnodesowegetthemarkupviewtooverflowhorizontallythisisaverylongtextnodesowegetthemarkupviewtooverflowhorizontallyOVERFLOWSMATCH
18+
</section>
19+
<section id="cropped-attribute"></section>
20+
<script>
21+
"use strict";
22+
23+
const sentence = "this is a very tall text node so we can check that search highlights are properly scrolled into view.";
24+
const textNodeContent = sentence.repeat(100);
25+
26+
// Split the search term in 2 here so we only have a single result when searching for it
27+
document.querySelector(".talltop").append(document.createTextNode("TALLTOP" + "MATCH" + textNodeContent));
28+
document.querySelector(".tallbottom").append(document.createTextNode(textNodeContent + "TALLBOTTOM" + "MATCH"));
29+
30+
// Keep in sync with devtools.markup.collapseAttributeLength
31+
const croppedAttributeEl = document.querySelector("#cropped-attribute")
32+
const collapseAttributeLength = 120;
33+
// Splitting in 2 so this won't be seen as a match
34+
const strToMatch = "cropped" + "value";
35+
const middle = Math.floor(collapseAttributeLength / 2);
36+
const cls = "-".repeat(middle) + strToMatch + "-".repeat(middle);
37+
croppedAttributeEl.classList.add(cls);
38+
</script>
1439
</body>
1540
</html>

0 commit comments

Comments
 (0)