A simple text highlighting component with React

In this post, we would create a simple React component which would allow a user to highlight selected text using a mouse. Also, it would also allow an optional callback function, which will receive the selection details.

Research on existing solutions

It’s always a good idea to search for existing well-tested components which may meet our requirements. After some quick search, all similar existing solutions seem to fall into the following categories:

  1. They accept text to be searched as props thus acting more like search/replace utilities, not allowing dynamic selection using mouse.
  2. They are part of bigger and complicated component libraries thus needlessly increasing dependencies.
    We don’t want both. So let’s create our own solution.

Ironing out the requirements

Our component will support the following props (inputs to the component):

  • text: Text to be shown to the user
  • selectionHandler (optional): A callback function. It will receive an object containing following details about selection.
    • selected text
    • selection start index
    • selection end index
  • customClass (optional): A user-provided CSS class to style the selected text

Component Code

import React, { Component } from 'react';
import PropTypes from 'prop-types';

const propTypes = {
    text: PropTypes.string.isRequired,
    customClass: PropTypes.string,
    selectionHandler: PropTypes.func
};

/**
 * Highlighter component.
 * 
 * Allows highlighting of the text selected by mouse with given custom class (or default)
 * and calls optional callback function with the following selection details:
 * - selected text
 * - selection start index 
 * - selection end index
 */
export default class HighLighter extends Component {
    constructor(props) {
        super(props);
        this.state = {
            text: props.text,
            isDirty: false,
            selection: '',
            anchorNode: '?',
            focusNode: '?',
            selectionStart: '?',
            selectionEnd: '?',
            first: '',
            middle: '',
            last: ''
        };
        this.onMouseUpHandler = this.onMouseUpHandler.bind(this);
    }

    onMouseUpHandler(e) {
        e.preventDefault();
        const selectionObj = (window.getSelection && window.getSelection());
        const selection = selectionObj.toString();
        const anchorNode = selectionObj.anchorNode;
        const focusNode = selectionObj.focusNode;
        const anchorOffset = selectionObj.anchorOffset;
        const focusOffset = selectionObj.focusOffset;
        const position = anchorNode.compareDocumentPosition(focusNode);
        let forward = false;

        if (position === anchorNode.DOCUMENT_POSITION_FOLLOWING) {
            forward = true;
        } else if (position === 0) {
            forward = (focusOffset - anchorOffset) > 0;
        }

        let selectionStart = forward ? anchorOffset : focusOffset;

        if (forward) {
            if (anchorNode.parentNode.getAttribute('data-order')
                && anchorNode.parentNode.getAttribute('data-order') === 'middle') {
                selectionStart += this.state.selectionStart;
            }
            if (anchorNode.parentNode.getAttribute('data-order')
                && anchorNode.parentNode.getAttribute('data-order') === 'last') {
                selectionStart += this.state.selectionEnd;
            }
        } else {
            if (focusNode.parentNode.getAttribute('data-order')
                && focusNode.parentNode.getAttribute('data-order') === 'middle') {
                selectionStart += this.state.selectionStart;
            }
            if (focusNode.parentNode.getAttribute('data-order')
                && focusNode.parentNode.getAttribute('data-order') === 'last') {
                selectionStart += this.state.selectionEnd;
            }
        }

        const selectionEnd = selectionStart + selection.length;
        const first = this.state.text.slice(0, selectionStart);
        const middle = this.state.text.slice(selectionStart, selectionEnd);
        const last = this.state.text.slice(selectionEnd);

        this.setState({
            selection,
            anchorNode,
            focusNode,
            selectionStart,
            selectionEnd,
            first,
            middle,
            last
        });

        if (this.props.selectionHandler) {
            this.props.selectionHandler({
                selection,
                selectionStart,
                selectionEnd
            });
        }

    }

    render() {
        if (!this.state.selection) {
            return (
                <span
                    onMouseUp={this.onMouseUpHandler}>{this.state.text}
                </span>
            )
        } else {
            return (
                <span
                    onMouseUp={this.onMouseUpHandler}>
                    <span
                        data-order="first" >
                        {this.state.first}
                    </span>
                    <span
                        data-order="middle"
                        className={this.props.customClass || "default"}>
                        {this.state.middle}
                    </span>
                    <span
                        data-order="last">
                        {this.state.last}
                    </span>
                </span>
            )
        }

    }
}

HighLighter.propTypes = propTypes;

Component Logic

Our challenge here is to get the text which got selected when the user selects a certain portion with the mouse. We use DOM’s window.getSelectionAPI for this. This API is called in the mouseup event. It returns a Selection object with many useful attributes and methods for our selection. The ones which are useful to us are:

  • anchorNode Node in which the selection begins.
  • anchorOffset A number representing the offset of the selection’s anchor within the anchorNode.
  • focusNode The Node in which the selection ends.
  • focusOffset A number representing the offset of the selection’s anchor within the focusNode.
  • toString() The selected text.

The anchor node and anchor offset give us the distance of start of the selection in terms of character. The end of the selection can be determined by adding the length of selection itself (given by toString method on the Selection object). These start and end point are then used to split our original text into three separate spans identified by unique data-attributes:

  • first text before the selection
  • middle selection text (style applied to this span to highlight it)
  • last text after selection

All these key info including endpoints, offsets, nodes, and text portions are kept in the components state. If an optional callback is provided, we call it with an object argument containing selection text and endpoints.

Although quite simpler on the surface, there are following hidden complexities in this selection logic:

  1. After the initial selection, new span nodes get introduced (to mark the highlighted text using CSS styling). These changes introduce new anchorNode/focusNode for each span along with relative offsets. For any further selections, we may then have to do relative adjustments depending on which span we started from using the earlier selection endpoints.
  2. Users might do the selection in reverse order. We then have to do our calculations with response to focusNode/focusOffset (shown in previous snippets). We use DOM’s compareDocumentPosition API (which gives us relative position nodes) along with the difference in the offsets to determine the direction of selection.

Note

There are few alternate ways to do the highlight, including Range.surroundContents API. However, these directly modify the DOM which is discouraged in React. We want to have as limited direct interaction with DOM as possible.

You can find the complete source code here, NPM package here and test it live here.

Leave a Reply