How we built the DevTools Tooltips
DevTools is where web developers spend a considerable amount of their time. Millions of people use it every day and it has become an amazingly useful product.
At the same time, it also has become quite complex. So complex that people often only use a subset of its functionalities, depending on the task at hand. It can easily feel overwhelming for people who are only just starting with web development, but even seasoned developers probably don’t know what all the panels in DevTools do.
With that in mind, our team at Microsoft started working on a new feature we call DevTools Tooltips. We think this will help users learn about the various panels in DevTools and what they can do.
If you’d like to try DevTools Tooltips yourself, you’ll need to enable an experiment as described here.
We’re excited that this feature is now available in Microsoft Edge Canary and we wanted to tell you a little bit about the behind the scenes of how we built it.
Below is a screenshot of what the feature looks like. Once enabled, it highlights all the panels that are visible in DevTools and provides tooltips with educational content about what the tools do, along with links to documentation.
Our starting point
When we started, we knew the Tooltips mode needed to behave as an overlay: something users could toggle, and when it was on, sit on top of the DevTools UI, highlighting its different parts.
We knew the highlighted parts needed to be configurable and associated with some text content, links and possibly images.
We also knew that the rest of the DevTools UI should remain inactive if the Tooltips were visible. The Tooltips were like a modal in a way, something on top of the rest of the user interface that you first need to get out of to continue using the tools.
However there was one exception to this rule: tabs should remain clickable. The idea was that this way, one would be able to navigate from, say, Console to Elements while in Tooltips mode and learn about those tools without having to toggle the Tooltips off and on again.
Finally, we wanted to make it possible to interact with the Tooltips with a mouse or a keyboard.
Some technical constraints
One of the design requirements for the feature was the ability to highlight not just rectangular shapes, but more complex polygons.
The screenshot below shows that to highlight the Elements pane, for example, we would have to draw a shape around both its tab and panel areas, together forming a complex polygon.
HTML and CSS are good for making rectangles, not so much for drawing the more complex shapes we were dealing with, so we decided to use SVG.
Another problem we faced is that some of the overlays needed to cover more than one component of DevTools.
In practice though, the areas we wanted to highlight didn’t always correspond to just one pane. Furthermore, the list of areas we highlighted will evolve over time as we write more documentation and add new tools in Chromium or directly in Edge. We would need to be able to react to any change to the DevTools UI.
So, we decided that the best option was to centralize the configuration for all the areas we wanted to highlight.
Our most complex challenge here was drawing an outline around an arbitrary shape. For example, if we wanted to highlight the Elements panel, we needed to draw an outline around several parts of its UI: the tab, the DOM tree, and the breadcrumbs.
These parts correspond to several DOM elements in the DevTools user interface, so the idea for drawing the outline was finding out where those elements are on the screen and creating a shape that corresponds to their union.
One tricky aspect of it was that in some situations, the elements we were trying to wrap didn’t intersect. For example, a tab and its corresponding tab panel have a few pixels gap in between them as shown in the screenshot below.
Turning to existing solutions to this particular problem, the term “Convex Hull” came up and sounded quite promising. It’s an algorithm that allows to wrap any number of points (on a plane) with a polygon that contains them all.
Below is a diagram (from Convex Hulls: Explained) showing how the convex hull algorithm can produce a polygon to wrap several points:
In our specific case, we could use this algorithm by giving it the list of the coordinates of all of the points from each of the DOM nodes we wanted to wrap, and it would return a polygon that wraps them.
After trying it on the DevTools UI though, it became clear that this wouldn’t be the right solution for us.
We couldn’t control exactly what the wrapping polygon ended up looking like. Unlike in situations where this algorithm is normally used, we only had a few points (8 in most cases) to work with, which caused it to generate shapes that were either too convex or not enough.
The screenshot below illustrates what the convex hull would look like in a basic tab and panel case.
As shown, it wasn’t possible to create a polygon that would stick to the sides of the elements we were wrapping since it was trying hard to be convex.
At this point, it seemed more reasonable to consider the list of DOM elements as a series of polygons and find a way to create a union polygon out of them, rather than thinking in terms of individual points with no relationship with the shape they describe.
Algorithms to perform Boolean operations on polygons do exist but are known to be complex. The Martinez-Rueda polygon clipping algorithm or polybooljs both seemed like good candidates for this but unfortunately don’t support dealing with polygons that don’t intersect.
We could have worked around this limitation by introducing some offsets around the DOM elements (for example add a couple of pixels below the tab element so it touches its corresponding panel), but the deciding factor was that these solutions amounted to several thousand lines of JS code that would have increased DevTools’ footprint quite a bit.
Simplifying the problem
So, we set out to list some assumptions that would make our life easier.
As a matter of fact we didn’t need to find a generic solution, equation or the perfect algorithm.
There were multiple aspects to our feature that made it simpler:
- To start with, we were only dealing with rectangles, not arbitrary polygons. We didn’t need to be able to wrap polygons with more than 4 sides, circles, etc.
- Secondly, we could assume that all of these rectangles would have their sides parallel to the X and Y axes of the document. They would not be rotated or otherwise transformed.
- And finally, these rectangles would either be intersecting or in some rare cases there would only be a small gap between them.
With these assumptions in place, it was easier to come up with a solution. We could put together some simple code that would work just for our case.
Here is the high-level description of the solution we put in place to draw the shapes.
Step 1 — Forcing intersections
The entry point to our drawing logic is a list of DOM elements. Each highlighted area has one or more DOM elements defined. Some of these might not intersect, and we know it’s a rare case and one that we do not want to deal with as it makes the drawing logic more complex.
So, for each of these, we added the ability to define an offset.
Taking the example of the Elements panel, here is what its Tooltips configuration looks like:
As you can see above, the tab element has a 2px downwards offset. This is used to “fill” the gap between the polygons we are trying to unite.
Step 2 — Finding the elements
Using the above CSS selectors, we find the corresponding elements in the DOM of the DevTools UI. DevTools uses web components and in particular relies on Shadow DOM quite intensively to encapsulate various parts of its UI. So finding the elements based on CSS selector isn’t as simple as using document.querySelector.
We had to write a special function to query elements across Shadow DOM boundaries:
The code snippet above uses a couple of interesting techniques:
- A TreeWalker to walk over the entire DOM tree, since we cannot rely on document.querySelector, we need another way to iterate over tree nodes.
- Recursion so that as soon as we find a nested Shadow DOM subtree (using element.shadowRoot), we call our search function again within that subtree.
Step 3 — Getting coordinates
Once we have our target elements, we use element.getBoundingClientRect() to get the coordinates of the 4 points for each of them, and that’s also when we need to apply our offsets.
Step 4 — Creating the shapes
Now that we have our coordinates for all the elements we want to wrap, this is where the fun begins. There are 3 steps here:
- Splitting segments
- Removing inner segments
- Ordering segments
For our first step, let’s assume that we have 2 input elements, corresponding to 2 squares that intersect as shown below:
Let’s imagine we extend all 4 sides of the squares infinitely and cut the other sides that these infinite lines.
This gives us a series of segments, exactly 8 per square in our case.
For our second step, we remove inner segments. To do this, we iterate over each segment one by one (remember we now have 16 of them) and for each, we check if it’s included inside another polygon. If it is, we remove it.
This means that we throw away all of the segments that are inside the combined shape, ending up with only segments that are on the outside of the combined shape.
The 2 polygons we started from now look like one combined polygon, made up of a series of segments. All we care about now is this list of segments since our goal is to create a single polygon.
The final step to do this is ordering segments.
It is an important aspect of the approach because we want to draw using SVG and to do this we need to create a <path> element which works with a list of draw commands that we want to execute in the right order.
So, this step is about sorting the segments in a way that they describe a path that goes around the resulting combined polygon from start to finish in one continuous line.
To do this, we pick a segment at random, since it doesn’t really matter where we start, and we then look at all of the other segments we have available, picking the one that starts where the previous one ended.
Here is some pseudo-TypeScript code to illustrate this:
The while (true) in the code snippet above might look like a possibility for the loop to become a performance problem.
However, the number of segments per shape is usually around 8, and the number of areas we are highlighting at any given point is usually around 5. Therefore, the loop only runs 35 times in typical cases. We made an educated guess that this number would never go higher than 100, making this sorting algorithm fast to run.
We end up with a sorted list of segments, as illustrated below:
Step 5 — Drawing the shapes
Now that we have a sorted list of segments for each of area we want to highlight, it’s time to render the shapes by using SVG.
We start by creating a list of points from our list of segments. We currently have a list like [segment AB, segment BC, segment CD] with repeated points. What we need is a list of points like [A, B, C D].
Next, using this list of points, we generate the SVG path string. We achieve this with the code below:
We end up with a path variable that looks something like this:
If you’re not familiar with SVG path strings, they’re a sequence of draw commands. The 3 commands we are using above are:
- M x,y: the Move command which instructs the path to go to an x,y point,
- L x,y: the Line command which draws a line from the current point to another x,y point,
- Z: the close command which closes the path, back to its starting point.
All that’s left now is rendering the path by providing this string to the <path> element:
The final details
Positioning the SVG
The way we measure the DOM elements we are highlighting means that all coordinates are absolute within the DevTools user interface document. Hence if we want the path to match with the area it is supposed to highlight, it needs to be displayed in an SVG that is absolutely positioned in that same document.
This is the CSS code we use to position the SVG element in the document:
Cleanly drawing the stroke
The highlighted areas have a 2px blue border around them to make them easily visible. It is also important that we draw this border on the inside of the path, and not outside.
The reason for this is that we almost always highlight more than 1 area at a time, and those areas are often very close to each other, with only a few pixels between them.
Drawing borders outside the areas would make them collide which would result in a less aesthetically pleasing UI.
SVG unfortunately does not yet support a way to control where the stroke around a shape is positioned. By default, setting stroke:2 on a shape draws that border in a center position, which ends up looking like this:
There is a proposal to add a way to position SVG strokes, but this hasn’t yet made it into the spec nor has been implemented by any browsers yet (last discussed in 2016 by the www-style group).
The way that we solved this was:
- Drawing a stroke twice as thick as we need.
- Clipping the entire SVG element to just the area of the path.
We draw a border that’s 4 pixels thick, twice the amount necessary. This means 2px are outside and 2px inside the shape. What we need to do now is to hide anything outside the shape.
It turns out that we already know the coordinates of the shape, and we can use the CSS clip-path property to do the clipping:
We were very lucky that Chromium started supporting path() functions in clip-path in time for our release (in version 88).
What this function allows you to do, is pass in an SVG-style path, which is exactly what we have, since we used it to draw the <path> already.
Masking the rest of the user interface
The final detail that we wanted to talk about is masking the rest of the user interface.
One of our requirements was to make it look like the parts of DevTools that were not highlighted were greyed out and therefore non interactive.
To achieve this, we use another full-page element, sitting above everything else.
And we then punch holes where the highlighted areas are.
And we can then display the highlighted areas without interference with the mask:
Clip-path turned out to be an ally once again to do this. Since we already have the coordinates of all the highlighted areas, we could use them to punch the holes in the mask.
And that’s it. Of course, there is more to the feature that we did not talk about here like keyboard navigation or reacting to changes in the UI, but these were interesting enough that we wanted to share.