How to create text annotations using React and TypeScript
#typescript #reactI changed my job last November and became a Full Stack Developer in a new team. Now I have lots of work related with frontend, especially with React
and TypeScript
. A few weeks ago, I had a task to implement annotations support in our application that we are developing. This article briefly describes the approaches that can be used to create an application with text annotations.
What is required?
Let’s imagine that we have the following text content:
We need to implement functionality that allows the user to add text annotations on the selected part of content. For example, such functionality in Google Docs:
To do that we should understand how to get information about selected text, how to save and restore it and how to highlight selected part of the text if the user adds annotation to it.
Selection and Ranges
DOM standard describes an interface named Range
, which represents part of the content between two boundary points. Using Range
, we can easily get the information about selection. To do that, just call getSelection
and then getRangeAt
:
const s = window.getSelection();
const range = s.getRangeAt(0);
Parameter range
is of interface Range
. This interface inherits another interface AbstractRange
which looks like:
interface AbstractRange {
/** Returns true if range is collapsed, and false otherwise. */
readonly collapsed: boolean;
/** Returns range's end node. */
readonly endContainer: Node;
/** Returns range's end offset. */
readonly endOffset: number;
/** Returns range's start node. */
readonly startContainer: Node;
/** Returns range's start offset. */
readonly startOffset: number;
}
The most important fields are startContainer
, startOffset
, endContainer
, endOffset
. These fields just determine selection. Let’s look at HTML code of the content that was shown above:
<div id="root">
<div>
<h5>What is Lorem Ipsum?</h5>
<p>
<strong>Lorem Ipsum</strong> is simply dummy text of the printing and
typesetting industry. Lorem Ipsum has been the industry's standard dummy
text ever since the 1500s, when an unknown printer took a galley of type
and scrambled it to make a type specimen book. It has survived not only
five centuries, but also the leap into electronic typesetting, remaining
essentially unchanged. It was popularised in the 1960s with the release of
Letraset sheets containing Lorem Ipsum passages, and more recently with
desktop publishing software like Aldus PageMaker including versions of
Lorem Ipsum.
</p>
</div>
</div>
For the considered example, selection range will be:
Field | Value |
---|---|
startContainer |
#text (child of <h5> ) |
startOffset |
12 |
endContainer |
#text (child of <p> ) |
endOffset |
74 |
Now let’s look at startContainer
and endContainer
differently. The nodes that stored by these fields can be determined by their indexes in the child arrays of the DOM tree. It means, that we can determine the addresses of the start and end nodes relative to the root
node by recursively traversing the DOM tree. Thus, the addresses will be [0, 0, 0, 0]
, [1, 1, 0, 0]
for startContainer
and endContainer
respectively:
[0] <div id="root">
|
╵--[0] <div>
|
|-- [0] <h5>
| |
| ╵--[0] #text <-- startContainer (startOffset: 12)
|
╵-- [1] <p>
|
|--[0] <strong>
|
|--[1] #text <-- endContainer (endOffset: 74)
|
...
This approach allows us to save selection data in the following class:
class SelectionData {
startNode: number[];
endNode: number[];
startTextOffset: number;
endTextOffset: number;
// other members
}
Functions for serializing Range
to SelectionData
and vice versa you can find here.
Content highlighting
Now we know how to save and restore information about selection, but how we can highlight the selected content? Function getClientRects
will help us with that. This function returns a list of DOMRect
objects.
interface Range extends AbstractRange {
getClientRects(): DOMRectList;
// other members
}
In our case, DOMRect
describes the size and position of the selected area of content.
interface DOMRectReadOnly {
readonly bottom: number;
readonly height: number;
readonly left: number;
readonly right: number;
readonly top: number;
readonly width: number;
readonly x: number;
readonly y: number;
toJSON(): any;
}
interface DOMRect extends DOMRectReadOnly {
height: number;
width: number;
x: number;
y: number;
}
Thus, using top
, left
, height
and width
we can create, for example, a <div>
element with calculated position and size to highlight the content. In a React
app, highlighting can be implemented something like this:
// Highlighting component
export default function Highlighting(props: {
highlighting: HighlightingData;
}) {
return <div style={styleHighlighting(props.highlighting)} />;
}
const styleHighlighting = (h: HighlightingData) => {
const s: CSSProperties = {
position: "absolute",
border: `1.5px solid darkgreen`,
borderRadius: "5px",
padding: "2px",
top: `${h.top}px`,
left: `${h.left - 2}px`,
width: `${h.width + 4}px`,
height: `${h.height}px`,
};
return s;
};
// Determines highlighted area
class HighlightingData {
top: number = 0;
left: number = 0;
right: number = 0;
bottom: number = 0;
width: number = 0;
height: number = 0;
}
Demo sandbox
The approaches described in this article were implemented in a demo application. You can find the source code in my GitHub profile as well as in the sandbox below.