Selection and JavaScript Range objects

Browsers treat these tools a little bit differently and I want to build something that I can use to retrieve and create page selections. I want to be able to collect information about the area of the page which the user has selected.

Important attributes of a Range object are startContainer, endContainer, startOffset, and endOffset with these attributes we can figure out what on the page is selected and even manipulate that selection. Containers denote the Nodes the selection starts and ends within. Offsets tell us how many characters to skip.

Pretend the following methods are enclosed inside a nice namespace.

export function getSelection (): Selection
{
    if (window.getSelection)
        return window.getSelection();
    else if (document.getSelection)
        return document.getSelection();
}

The standards compliant way to retrieve the current page selection is with getSelection. In this article we are not supporting IE8. If you are looking for a complete solution there is quite an extensive library called rangy. The Selection object returned by this method doesn't give us all of the information we need.

export function getRange (element?: HTMLElement): Range
{
    let selection = getSelection();
    if (!selection)
        return;

    let range: Range;
    if (selection.getRangeAt)
        range = selection.getRangeAt(0);
    else {
        range = document.createRange();
        range.setStart(selection.anchorNode, selection.anchorOffset);
        range.setEnd(selection.focusNode, selection.focusOffset);
    }

    if (!element)
        return range;
    if (element.contains(range.startContainer) && element.contains(range.endContainer))
        return range;
}

Safari can behave a little bit strangely so in some cases it is necessary to build our Range object ourselves. Now we have the area which is selected on the page.

I've added the ability to only return the selection if it exists within a specific element. If the provided element contains both container nodes then we know that the a range is from within a specific area.

export function setRange (range?: Range)
{
    let selection = getSelection();
    if (!selection)
        return;

    selection.removeAllRanges();
    if (range)
        selection.addRange(range);
}

This will select the area of the page you define in a Range. The anatomy of a Range object is as stated earlier the container and offset of both the start and end of the area. Keep in mind there is a bit of difference between a div element, and the textnode or nodes it may contain. It is therefore often necessary to use the element's firstChild when defining a range.

interface IRangePosition
{
    node: Node;
    offset: number;
}

export function createRange (positionStart: IRangePosition, positionEnd?: IRangePosition): Range
{
    if (!positionStart)
        return;

    let range = document.createRange();
    range.setStart(positionStart.node, positionStart.offset);

    if (positionEnd)
        range.setEnd(positionEnd.node, positionEnd.offset);
    else
        range.collapse(true);

    return range;
}

This helper will assist in creating ranges. A range which is collapsed is just simply a cursor position, which will be useful in cases where you are working with contenteditable divs.

In addition it might be useful to ignore everything to do with divs and find a IRangePosition based on a character index. If you can imagine the following function finds a Node and offset for you which you can use to create a range.

export function findPosition (parent: Node, index?: number): IRangePosition
{
    if (!index && index != 0)
        return;

    let position = 0;

    for (let i = 0; i < parent.childNodes.length; i++) {
        let node = parent.childNodes.item(i);
        let length = node.textContent.length;

        if (position + length >= index) {
            if (node.nodeType == 3)
                return { node: node, offset: index - position };
            else
                return findPosition(node, index - position);
        }

        position += length;
    }

    return;
}

We walk through the tree regularly checking the length of the text content in each childNode in order to pinpoint the correct node and offset within it.

This will work as a starting point towards normalising selections a little bit.