Caret position and selection within TextAreas and Inputs

Building a class that plays around with caret position and selection in a TextArea or Input element.

typescript
interface ICaretInfo
{
    start: number;
    length: number;
    text: string;
}

class CaretController
{
    element: HTMLInputElement | HTMLTextAreaElement;

    constructor (element: HTMLInputElement | HTMLTextAreaElement)
    {
        this.element = element;
    }

    hasFocus (): boolean
    {
        return document.activeElement === this.element;
    }
}

We provide our CaretController with an element and define an interface that describes caret information to be extracted.

typescript
getInfo (): ICaretInfo
{
    if (!this.hasFocus())
        return { start: -1, length: 0, text: "" };

    let start = this.element.selectionStart;
    let end = this.element.selectionEnd;

    return {
        start: start,
        length: (end - start),
        text: this.element.value.substring(start, end)
    };
}

If the element has focus we return some information about the currently selected text, including the selected text itself. If no text is selected this method will still return useful information in the form of the current caret position.

typescript
setPosition (start: number, length: number = 0)
{
    if (start <= -1) return;
    if (length < 0) length = 0;

    this.element.selectionStart = start;
    this.element.selectionEnd = start + length;
}

When setting a position we can optionally provide a length which will cause a selection.

typescript
private _insert (text: string, start: number, length: number)
{
    let value = this.element.value;
    this.element.value = value.slice(0, start) + text + value.slice(start + length);
}

insert (text: string)
{
    let info = this.getInfo();
    if (info.start <= -1) return;

    this._insert(text, info.start, info.length);

    if (info.length) {
        // select inserted text
        this.setPosition(info.start, text.length);
    }
    else {
        // put caret after inserted text
        this.setPosition(info.start + text.length);
    }
}

When we insert we want it to overwrite the selected text, and for the newly inserted text to be selected in its place. If we have nothing selected we want the caret to appear after the inserted text.

typescript
insertAt (text: string, start: number, length: number = 0)
{
    if (start <= -1) return;
    if (length < 0) length = 0;

    let info = this.getInfo();

    this._insert(text, start, length);

    if (info.start <= -1) return;

    // selection end position
    let end = info.start + info.length;

    if (end > start) {
        // selection ends after inserted text starts
        let change = text.length - length;

        if (info.start > start) {
            // selection starts after inserted text starts
            info.start += change;
            if (info.start < start) info.start = start;
        }
        else if (start + length <= end) {
            // inserted text exists entirely within selection
            info.length += change;
        }
    }

    this.setPosition(info.start, info.length);
}

If we are inserting text at an arbitrary location we want what was selected before to stay the same. This gets a little bit hacky and can probably be adapted to better suit your situation. We collect the current caret information, perform an _insert, and then try to adapt the new selection to match what it was as best as we can.

If the selection ends before the changes start we can simply select the same thing as before, otherwise we need to make changes. Moving either the starting position or altering the selection length.

Circumstantially it is possible to finish with a start position less than the position of the insert, in which case we just advance it a bit so we don't end up selecting something which wasn't selected before. It's also possible that length will drop below 0 in which case the selection will be collapsed by our setPosition method.

Those cases shouldn't come up often though. Most of the time we're just bumping the caret around. After all that this utility class gives us some reasonable degree of control over text field carets and our interactions/manipulation of them programatically.