window.getSelection() in JavaScript - The torment of text selection

Video thumbnail

I'm going to explain something that I think you'll find very interesting: when the user selects text, you can set options like highlighting the text and adding a note; for this, there is a JS function:

window.getSelection();

What is window.getSelection() and how does it work

If you've ever wanted to know what text a user is selecting on your page, window.getSelection() is your best friend. This function returns a Selection object that represents the range of selected text or the current cursor (caret) position within the document.

It returns an object with properties such as:

  • anchorNode and focusNode: start and end nodes of the selection.
  • anchorOffset and focusOffset: exact positions within those nodes.
  • isCollapsed: whether the selection is empty.
  • rangeCount: how many ranges the selection contains.

Note: document.getSelection() works practically the same and, in fact, in many cases you can use it as a synonym.

Get the selected text in JavaScript

To test the previous method:

const selectedText = window.getSelection().toString();
console.log(selectedText);

Open a browser window, the developer console, select a text on the web page, and then execute the previous line of code and you will see an output like the following:

Selection {anchorNode: text, anchorOffset: 9, focusNode: text, focusOffset: 47, isCollapsed: false, …}
anchorNode
: 
text
anchorOffset
: 
9
baseNode
: 
text
baseOffset
: 
9
extentNode
: 
text
extentOffset
: 
47
focusNode
: 
text
focusOffset
: 
47
isCollapsed
: 
false
rangeCount
: 
1
type
: 
"Range"

Problems with window.getSelection();

The main problem with using window.getSelection(); is that if we don't have a clean paragraph like the following:

<p id="p_1739646025565_337622">Laravel, al igua<span title="Test" id="_p_1739646025565_337622" class="bg-purple-700 rounded-sm no-set-note text-white">l que otros frame</span>works, puede&nbsp;ejecutarse en diversos entornos tanto en ambiente de producción como de desarrollo:</p>

But something like:

<p id="p_1739646025565_337622">Laravel, al igua<span title="Test" id="_p_1739646025565_337622" class="bg-purple-700 rounded-sm no-set-note text-white">l que otros frame</span>works, <span id="span_1739646025565_102820">puede&nbsp;ejecutarse en diversos entornos tanto en ambiente de producción como de desarrollo:</span></p>

And if, for example, we want to select the text “iversos entornos tanto en ambien” (i.e., the text right next to the SPAN), the defined range will start from the SPAN and not the whole paragraph. Therefore, any operation we want to perform, the position will be linked to the SPAN and not the paragraph, which is a big problem since it's not possible, at least in a simple way, to create the ranges starting from the paragraph.

Common problems and limitations

In my experience, there are several pitfalls that don't always appear in the official documentation:

  1. Ranges broken up by internal elements
    1. If your paragraph has internal <span>s, the range is sometimes generated from the span and not from the entire paragraph. This breaks calculations for bubble position and other operations.
  2. Use in inputs or textareas
    1. window.getSelection() doesn't work well in Firefox or Edge Legacy for <input> and <textarea> content. The solution is to use setSelectionRange() or the selectionStart and selectionEnd properties.
  3. Reconstruction of saved ranges
    1. For persistent notes or highlights, I recommend saving the node ID, start, end, and an extract of the text. This way you can recreate the highlight later without problems.

Retrieve the range, highlight text and create note bubbles

We must register the range if we want to be able to create it on demand, for this:

this.$axios
.post(this.$root.urls.bsnCreate + this.section.id, {
  title: this.form.title,
  content: this.form.content,
  json: {
    text: this.bubbleSelectecOptions.selectionText.substring(0, 10),
    id: this.bubbleSelectecOptions.selectionId, // Guarda el ID del elemento padre
    start: this.bubbleSelectecOptions.selectionRangeStart,
    end: this.bubbleSelectecOptions.selectionRangeEnd
  }

In my case, I use it to draw a bubble with the option to create a comment:

const rect = rango.getBoundingClientRect();

posX = rect.left + window.scrollX;
posY = rect.top + window.scrollY - 40;

Then, I construct a dynamic span:

const span = document.createElement("span");
span.title = "Mi nota";
span.className = "bg-purple-700 rounded-sm no-set-note text-white";

And the best part is that, if I saved the range before, I can reconstruct it later:

const node = document.querySelector("#" + id).firstChild;
const rango = document.createRange();
rango.setStart(node, start);
rango.setEnd(node, end);

The complete code looks like this:

async addHighlights(id, start, end, noteTitle, noteContent) {
  // const node = document.querySelector("#" + id).firstChild.nodeValue
  if (id.trim() == "")
    return this.$toast.warning(this.$t('large.noteBookRangeNoValid'))

  const node = document.querySelector("#" + id).firstChild
  const rango = document.createRange();

  try {
    rango.setStart(node, start);
    rango.setEnd(node, end);

    const span = document.createElement("span");
    span.title = noteTitle;
    span.id = "_" + id;

    span.onclick = function () {
      this.bubbleSelectecOptions.titleNote = noteTitle
      this.bubbleSelectecOptions.contentNote = noteContent
      this.bubbleSelectecOptions.showModalNoteUser = true
      this.bubbleSelectecOptions.modalKey3 = Date()
    }.bind(this);

    span.className = "bg-purple-700 rounded-sm no-set-note text-white";

    rango.surroundContents(span);
  } catch (error) {
    console.error(error)
  }
},

Best practices and advanced tips

  • Handle partial nodes carefully: surroundContents() can fail if your range includes incomplete nodes.
  • Cross-browser compatibility: test in Chrome, Firefox, Edge, and Safari.
  • Optimization: avoid traversing the entire DOM every time you want to detect a selection; work with specific parent nodes.
  • Persistence: always save the node ID and offsets to be able to reconstruct ranges without errors.

Frequently Asked Questions (FAQ)

  • 1. What is the difference between window.getSelection() and document.getSelection()?
    • Both return the same Selection object type, but document.getSelection() always refers to the current document, while window.getSelection() can be used in broader window contexts.
  • 2. How to get the exact text of a range?
    • Use selection.toString() or range.toString() to get only the text, without metadata.
  • 3. Why does it fail when there are internal elements like SPANs?
    • The range is generated from the most internal node, not the main container. The solution is to reconstruct it using the parent node's firstChild.
  • 4. Can it be used in inputs or textareas?
    • In most browsers, yes, except Firefox and Edge Legacy, where you must use setSelectionRange().
  • 5. How to reconstruct saved ranges for persistent notes?
    • Save the node ID, start, end, and an extract of the text. Then recreate the Range() and apply surroundContents().

I agree to receive announcements of interest about this Blog.

I talk about how I am implementing a functionality for text selection in JavaScript and being able to perform text highlighting and a note.

| 👤 Andrés Cruz

🇪🇸 En español