Selection and JavaScript Range objects

Browsers treat these tools a little bit differently and I want to build something that I can use to more easily manage selections. The area of the page which the user has selected, and to select areas on their behalf.

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 node that the selection starts and ends within. Offsets tell us how many characters to skip.

Pretend the following methods are enclosed inside a nice namespace.

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

Note: In this article we are not supporting IE8, if you are there is quite an extensive library called rangy which you should look at.

The standards compliant way to retrieve the current page selection is with getSelection. The Selection object returned by this method doesn't give us all of the information we need by itself.

typescript
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 manually. 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.

typescript
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 within a range. The anatomy of a Range object as stated earlier, is the container and offset at both the start and end of the area. Keep in mind we want to use textnodes, there is a bit of difference between a div element, and the textnode or other nodes it may contain. It is often necessary for example to use the element's firstChild attribute when defining Range.

typescript
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 from plain key value pairs.

A range which is "collapsed" is 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 an 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.

typescript
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 undefined;
}

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 on your page.