Dynamic Insets That Preserve Visual Lines
Table of Contents
The Intro
What would you do if you could target line breaks in web pages? The goal of this article is to explain what that question means, explore some fun answers, and hopefully start a broader discussion on the topic.
I started this article as an exploration of but I soon realized that the question was bigger and more interesting than my one narrow answer. I want to discuss the broader topic, and so I want to explain the basics to engage as many people as possible with the topic.
Browsers have handled line breaks since their inception. And they do it so well that web developers barely ever think about it. One time it comes up is when a graphic designer designs for the web, they might expect to have control over typesetting paragraphs of text. I've had several conversations with graphic designers where I've had to explain that I had no idea how to ensure there would never be widows (?) on their webpage.
Boxes appear on web pages all the time, in all different places and contexts. In this article, I present a novel way to position boxes which dynamically interject between two visual lines of inline text with no disruption to the flow of that text. I call these boxes "insets," which I hope alludes to a familiar graphic design concept.
Back to topThe Goods
I have a lot to say about the what, why, and how. But before that, I figure I should give you an example of the result I'm looking for. So you can to make a little quote appear. The point of this project is for that quote not to interrupt the flow of the paragraph surrounding it. So, in a very boring way, nothing should change except for a quote appearing when you click that button. Oh, and you can to remove the quote from up there and move it back to here. That way you can get some picture that this is dynamic. Especially fun to try would be resizing this paragraph with the little handlebar on the lower right hand side, and then clicking both buttons to see how the flow of the paragraph does or does not change around it.
Back to topI've got gadgets and gizmos aplenty, I've got whosits and whatsits galore - Ariel, The Little Mermaid
Goals
The description of the concept sure is a mouthful. One of my hopes in sharing this article is that it sparks some conversations that lead to brainstorms of better descriptions of the goal of this work. I would also appreciate help to find a simpler or more effective method to achieve the goal, since my method feels both complex and fragile.
I hope this article intrigues several audiences such as web developers, browser developers, graphic designers, interface designers, and product designers. For a different topic, I might chop up several more focused chunks for each intended audience instead of one big'n. But here, the conceptual purpose is to enable different folks to dig into different aspects of a complicated subject, so I kept it all together. Please disregard any JavaScript, jargon, or jokes you don't jive with.
Back to topBaby Steps
There's a surprising amount of complexity to unpack to present a full picture of my goal. So in this section I'm going to work step-by-step from the fundamentals of web development up to the whole shebang.
Take for example a blockquote element like this one:
Toto, I've a feeling we're not in Kansas any more. - Dorothy Gale, The Wizard of Oz
The quote above is an example of an "inset." I got this word "inset"
from a graphic designer friend. What they told me, in my own words, is
that an inset is a design element which is distinct from the surrounding
area but whose content is contextually relevant. If you know a better
word, please let me know. Here's another
aside element used as an inset with some more commentary
from me:
So you have two examples of insets already, the
blockquote and the aside above. They're
sitting there, inert in the flow of the document. They don't interject,
they wait their turn.
Now imagine that one of those could just appear out of nowhere. In fact,
you don't have to imagine it, you can
to see one pop up below this paragraph. I'll also include a number (e.g.
#1), so when you click the button multiple times, you can
distinguish the order in which each appeared.
Note the position of the previous button is inline within a paragraph.
So, note the distance between the button in the prior paragraph and the effect of that button. Sure, it's only a couple lines of text away. If you're on a big screen, you may not even know what I mean by "distance." It might feel like that new inset appears immediately after the button. But if you're on a small screen, that button might be near the bottom of your screen, in which case the effect of the button is completely invisible! And that distance only gets larger as the width of the paragraph shrinks, and the text extends downwards, even further from the button.
So, that's the issue. Now, the first solution I imagined was: what if we could do the same thing, have an inset magically appear, but it appears immediately below the button. At first glance, this feels like a simple and sufficient solution. Let's try it and observe what happens. to insert another quote. This one is a bit different in that more text will be added over a period of 10 seconds. Hopefully you'll see the problem I feel with this.
Because the blockquote element is a
block element, it immediately separates the
inline button immediately before it, and the
inline text node after it. The outcome is it that it looks
like 3 block elements stacked ontop of each other. This
disrupts the flow of the sentence. Depending on your screen width and
the font-size, it may also create a scenario where the button is sitting
on a row all by itself. In short, it works, but it feels awkward. And it
feels awkward enough taht I wouldn't want to use it.
To get the best of both of these scenarios, we'd like text to flow exactly as it did before the new inset appeared, up to the point where a new block element appears, and then for the text to continue below it exactly as if it was always supposed to be this way.
To show what I'm looking for, I have this static example below. It's a
blockquote with an aside interjected. Sorry,
no buttons this time. But we'll be back to that soon.
One fish two fishred fish blue fish - Dr. Seuss, "One fish two fish red fish blue fish"
Imagine the button which created this dynamic effect was on the word
two
in the poem above. That would result in fish
being
split off from it inappropriately. So how would we make this happen?
It's obnoxiously hard to achieve because the algorithm for flowing text on a webpage doesn't easily allow for it. The vagueness of this statement might suggest that this is the limit of my understanding of that algorithm, so maybe there is a simpler solution deep in the heart of the HTML spec. Otherwise, my understanding is that mostly HTML expects this sort of concept to be achieved via manually or programattically splitting text notes into different elements or separate text nodes.
So, that's exactly the plan. The trick will be to determine where to
split the text nodes up. To do that, we'll have to figure out where the
line break currently is before making the split. And that's most of the
business of achieving this. My thought on how to achieve this is to use
the JavaScript
Range API. A close cousin of Range is Selection. If
you've ever selected text on a webpage by clicking and dragging with
your mouse on text or long-pressing and dragging with your finger,
you've created a Selection in the native way. And if you
have, then you have some intimate, first-hand feel for how
Range works as well. With a Range, we can make
the JavaScript parallel of a native selection. We can also literally
select text and other HTML elements this way.
So, my idea is to create a Range object around the pressed
button, and then move it and shift it and calculate it to discover
exactly where the text breaks into a new line. And when I find the place
where the text breaks, I will split it there into two separate elements.
Finally, once the text is split, I can insert the new inset between the
two elements as I've done already.
To begin, you can to select this text (from the previous word "select" to the period after this parenthetical). When you press the button, you should see your native selection appear.
A Range works exactly the same way, but it's invisible. In
fact, the prior button also created a separate, new range which included
the same text. Well, technically, a selection can contain multiple
ranges, but we can ignore that complication for now. (Note to self, come
back to research this complication later. How does a selection with
multiple ranges get formed natively? If it can only be formed through
JavaScript, maybe this is not very interesting)
From here, the next experiment I wanted to do was to exactly select one
line of text. My idea was to add the button itself to the range, then
calculate the range's height using the
Range: getBoundingClientRect() method. Once I had that base height, I could extend the range in either
direction until the height changed, and then back track to find the
maximum extent of the range that maintains that base height. So you can
, and it should select exactly the visual line as you see it. This is
an interesting result because this is all one text node within a single
p (paragraph) element. Compare with the previous
experiment, where I made a span element to target
specifically. Again, the goal is to create an invisible range. The
selection is just copying that result to visually show the
accomplishment. There's an additional complication here that if you
resize the browser with this selection, the visual line layout might
change. In that case, the selection should reset.
Once I could show a range surrounding a rendered line, I felt my solution was theoretically sound. I wasn't convinced that it was practically sound because I hadn't tested every edge case exhaustively. I could imagine a lot of edge cases, like:
- Arbitrarily nested DOM elements near the rendered line break
- Different font sizes within a rendered line
- Different box model adjustments like vertical padding within a rendered line
With that many edge cases which I could imagine, I guessed there were
many more edge cases I failed to imagine waiting just around the corner.
With known unknowns and unknown unknowns lurking, I could spend a lot of
time trying to achieve that basic selection example for as many
real-world cases as possible. Instead of spending that time, with a
theoretical win under my belt, I decided to press forward to see my
immediate goal functional. So, if you
a new inset should appear beneath the current visual line. And that
visual line should not change at all. The Range API only has an
insertNode at the start, not at the end. Luckily this
StackOverflow answer
presents an elegant solution to collapse the Range to its end, to make
its start and end the same. Oh, and you should be able to
to make a new inset appear above this visual line! The solutions for
above and below should be exactly the same. As I wrote this, I only
imagined a use-case for creating a new inset beneath the current line,
but since they were so similar, it wasn't much effort to achieve both at
the same time.