Creating a Medium-like text highlighter for typo reporting

Public comments should not be the place to point out spelling mistakes.

Choice of UI

We decided to use a contextual menu that opens like the Medium highlighter.

Example

Some other news sites use other means such as a dedicated email address or a simple contact form, but we knew that these methods would be hijacked for sending press releases that would then pollute the processing of typos 😬️.

This method also makes it easier to give context of the typo among the content of the article in order to find it in the content.

The final result in action:

Final result

No library suits us

There are not many JS libraries available on this topic, or I was not able to find the right keywords to make my searches.

Here are the libs I found and why we didn't go with them :

  • typo-reporter (JS script opening pop-up to send typo report): quite similar to what we did, but there is no contextual menu
  • medium-editor (a clone of medium.com inline editor toolbar): a bit too much as we just want the highlight/select feature
  • jquery.textSelect (jQuery extension to work with text selection): depends on jQuery, not maintained for a long time and does not bring significant feature such as the contextual menu
  • Highlighter pro (WordPress extension): too heavy for what we need
  • 👍️ TinyQ (zero dependency JS lib)
  • 👍️ Highlight share (Medium-like text selection sharing)

Finally, we decided to go from scratch, as it seems that not much code needs to be written. And if the user selects only one word, it will not be easy to find it if we don't have the surrounding text.

Opening the popover on text selection

The position of the popover is above the first line of selection by default. If the first line is above the top of the screen or there is not enough space to place the popover, we put it below the last line. On mobile, a native contextual menu is displayed upon text selection with features like Copy or Select all which could hide our popover, so we put it below the first line by default and above the last line if the first line is too up. This responsive positioning is done with CSS.

const popoverContainer = document.querySelector( '.wrapper' );

/**
 * Returns an object with keys:
 * - left, top: position relative to `popoverContainer`
 * - pos: 'above' if popover is linked to first line or 'below' for last line
 */
function GetPopoverPosition() {
    // the list of rects for all lines of the selection
    const rects = document.getSelection().getRangeAt( 0 ).getClientRects();

    // pos is below if we don't have enough space above
    let pos = rects[0].top < 50 ? 'below' : 'above';
    // rects values for the first or last line depending on pos
    let { left, top, width, height } = pos === 'above' ? rects[0] : rects[rects.length - 1];

    // adjust around line: center horizontally, align to top or bottom depending on pos
    left += width / 2;
    if ( pos === 'below' ) {
        top += height;
    }

    // adjust with scroll in screen
    left += window.scrollX;
    top += window.scrollY;

    // adjust with parent position relative to body
    const bodyRect = document.body.getBoundingClientRect();
    const parentRect = popoverContainer.getBoundingClientRect();
    left -= parentRect.left - bodyRect.left;
    top -= parentRect.top - bodyRect.top;

    return { left, top, pos };
}

Then we create the open and close functions for the popover:

function OpenPopover() {
    const { top, left, pos } = GetPopoverPosition();

    // create positioned popover element
    const popover = document.createElement( 'button' );
    popover.className = 'typo-report-popover typo-report-popover--' + pos;
    popover.style.top = top + 'px';
    popover.style.left = left + 'px';

    // without this, the click on the popover clears the text selection
    const stopEvent = ( e ) => {
        e.preventDefault();
        e.stopPropagation();
    };
    popover.addEventListener( 'mouseup', stopEvent );
    popover.addEventListener( 'touchend', stopEvent );

    popoverContainer.appendChild( popover );
}

function ClosePopover() {
    popoverContainer.querySelectorAll( '.typo-report-popover' )
                    .forEach( e => e.parentElement.removeChild( e ) );
}
.typo-report-popover {
    background-color: #f0eff4;
    border: 0;
    border-radius: 2px;
    box-shadow: 0 0 4px rgba(0, 0, 0, .6);
    display: block;
    height: 36px;
    position: absolute;
    transform: translateX(-50%);
    width: 48px;

    &::before {
        background-image: url('spellcheck.svg');
        background-size: contain;
        background-position: center center;
        background-repeat: no-repeat;
        bottom: 6px;
        content: '';
        display: block;
        left: 12px;
        position: absolute;
        right: 12px;
        top: 6px;
    }

    &::after {
        background-color: #f0eff4;
        content: '';
        height: 8px;
        position: absolute;
        width: 8px;
    }

    @mixin above() {
        margin-top: -4px;
        transform: translate(-50%, -100%);

        &::after {
            bottom: 0;
            box-shadow: 4px 4px 4px rgba(0, 0, 0, .3);
            transform: translate(-50%, 50%) rotate(45deg);
        }
    }
    @mixin below() {
        margin-top: 4px;

        &::after {
            top: 0;
            box-shadow: -4px -4px 4px rgba(0, 0, 0, .3);
            transform: translate(-50%, -50%) rotate(45deg);
        }
    }

    &.typo-report-popover--above {
        @include above;

        @media (max-width: 768px) {
            @include below;

            transform: translate(-50%, 100%);
        }
    }

    &.typo-report-popover--below {
        @include below;
    }
}

The popover

Then we need to toggle the popover when the user selects text or when the selection is cleared. The most obvious JS event is selectionchange and we also worked with mouseup and touchend to ensure compatibility for older browsers.

function TogglePopover() {
    // timeout to avoid blinking
    setTimeout( () => {
        // close previous popover
        ClosePopover();

        // open new popover if there is selected text within the highlightable elements
        const selection = document.getSelection();
        if ( selection.type === 'Range' && !selection.isCollapsed && !!selection.toString().trim() ) {
            OpenPopover();
        }
    }, 100 );
}
document.addEventListener( 'mouseup', TogglePopover );
document.addEventListener( 'touchend', TogglePopover );
document.addEventListener( 'selectionchange', TogglePopover );

Retrieving the text selection, with its context

In the selection, we can have undesired text content such as <script> or <img>. So we have to filter it out:

const excludedContentSelectors = [ 'script', 'img', 'noscript', 'iframe', '.print' ];
const excludedContentSelector = excludedContentSelectors.map( d => `${d},${d} *` ).join( ',' );

function SelectionToString() {
    const range = document.getSelection().getRangeAt( 0 );
    const fragment = range.cloneContents();

    // filters invisible nodes that should not be included in the text (meta data, print, scripts, images, etc)
    FilterChildren( fragment );

    return fragment.textContent;
}

function FilterChildren( node ) {
    const newChildren = Array.prototype.map.call( node.childNodes, ( child ) => {
        if ( child.nodeType === Node.TEXT_NODE && !!child.textContent ) {
            return child;
        } else if ( child.nodeType === Node.ELEMENT_NODE && ! child.matches( excludedContentSelector ) ) {
            FilterChildren( child )
            return child;
        } else {
            return null;
        }
    } ).filter( ( child ) => !! child );
    node.replaceChildren.apply( node, newChildren );
}

To be able to find the typo easily among the whole article, we need to send to the editor the selected text but also some context around it. This means we need to extend the beginning of the selection before and the end after what the user actually selected.

To do this, we browse up the DOM from the selected nodes to their parents until we match a non-inline element. Thus, if a user selects a word within a <strong> or <i> markup, we don't get the content of this markup only which can be very short, but the parent <p> element. This is also important not to go too up in the DOM tree so that we do not send the whole article content as context.

The selected content can contain some HTML markup, and we also try to filter it out.

So we define a set of elements for which the direct children are the boundaries when we go up in the DOM to find a block element, filtering the text content:

const highlightableElementSelectors = [ 'h1', '.article-content' ];
const highlightableSelector = highlightableElementSelectors.join( ',' );

function GetSelectionWithContext() {
    const selection = document.getSelection();

    // save current selection
    const { startContainer, startOffset, endContainer, endOffset } = selection.getRangeAt(0);

    // retrieve start as the first block element in parents, excluding full content
    let start = startContainer;
    while ( ! start.parentElement.matches( highlightableSelector )
            && (
                start.nodeType === Node.TEXT_NODE
                || (
                    start.nodeType === Node.ELEMENT_NODE
                    && window.getComputedStyle( start ).display === 'inline'
                )
            )
        ) {
        start = start.parentElement;
    }

    // retrieve end as the first block element in parents, excluding full content if it's not the last element of full content
    let end = endContainer;
    while ( ! end.nextSibling
            || (
                ! end.parentElement.matches( highlightableSelector )
                && (
                    end.nodeType === Node.TEXT_NODE
                    || (
                        end.nodeType === Node.ELEMENT_NODE
                        && window.getComputedStyle( end ).display === 'inline'
                    )
                )
            )
        ) {
        end = end.parentElement;
    }

    // change selection to get context before and after
    selection.setBaseAndExtent( start, 0, startContainer, startOffset );
    const selectedContextBefore = SelectionToString();
    selection.setBaseAndExtent( endContainer, endOffset, end.nextSibling, 0 );
    const selectedContextAfter = SelectionToString();

    // rollback to user selection and return context
    selection.setBaseAndExtent( startContainer, startOffset, endContainer, endOffset );
    const selectContent = SelectionToString();
    return `${HTMLEntities( selectedContextBefore )}<mark>${HTMLEntities( selectContent )}</mark>${HTMLEntities( selectedContextAfter )}`;
}

function HTMLEntities( str ) {
    const div = document.createElement( 'DIV' );
    div.innerText = str;
    return div.innerHTML;
}

The call of GetSelectionWithContext() gives this kind of results, the actual selection of the user being 'une vaste retrospective' among a paragraph of text:

Selection with context

To avoid text selection on the whole page but limit it to the highlightableElements, we could modify the TogglePopover function (using jQuery helper function):

function TogglePopover() {
    // timeout to avoid blinking
    setTimeout( () => {
        // close previous popover
        ClosePopover();

        // open new popover if there is selected text within the highlightable elements
        const selection = document.getSelection();
        if (
            selection.type === 'Range' && !selection.isCollapsed && !!selection.toString().trim()
            && jQuery( selection.anchorNode ).parents( highlightableSelector ).length > 0
            && jQuery( selection.focusNode ).parents( highlightableSelector ).length > 0
        ) {
            OpenPopover();
        }
    }, 100 );
}

Opening a modal to suggest a correction

When the user clicks on the popover, a modal opens showing the selected text in context and a field, so the user can type a suggestion for a correction.

The call of GetSelectionWithContext() could be a bit heavy depending on the DOM structure, so it is good that it is called only once when the user has decided on the selected piece of text.

For this first implementation, we use the convenient jsmodal.

function OpenReportPopup() {
    const selectionWithContext = GetSelectionWithContext().replaceAll( /\t/g, '' )
        .replaceAll( /  +/g, ' ' )
        .replaceAll( /(\r?\n)+/g, '<br>' )
        .replaceAll( /<br>(<br>)+/g, '<br>' );

    // open modal
    const content = '<div class="typo-report-popup">' +
                    '<h2>Report a typo</h2>' +
                    `<blockquote>${selectionWithContext}</blockquote>` +
                    '<h3>Your correction:</h3>' +
                    '<textarea class="fix-proposal" placeholder="Type here the correction of the yellow text"></textarea>' +
                    '<button class="send-fix-proposal">Send the correction</button>' +
                    '<div class="error"></div>' +
                    '</div>';
    Modal.open( {
        content: content,
        left: '50%',
        draggable: false,
        openCallback: () => {
            document.querySelector( 'html' ).style.overflow = 'hidden';
            document.querySelectorAll( '.send-fix-proposal' )
                    .forEach( ( send ) => {
                        send.addEventListener( 'click', () => {
                            send.innerHTML = 'Sending…';
                            send.disabled = true;
                            SendTypoFix( selectionWithContext, document.querySelector( 'textarea.fix-proposal' ).value );
                        } );
                    } )
        },
        closeCallback: () => {
            document.querySelector( 'html' ).style.overflow = '';
        }
    } );
}

function SendTypoFix( markedSelection, fix ) {
    // TODO: your implementation
}

The modal

Then we add the event listener on the popover in the function OpenPopover:

popover.addEventListener( 'click', OpenReportPopup );
popover.addEventListener( 'touchend', OpenReportPopup );

🚀️ What's next?

Now we have to implement the sending of the typo report through the preferred channel of the website editor: e-mail, team messaging, etc.

We could also limit the size of the text before and after to avoid useless huge piece of text to be sent.