Creating a Medium-like text highlighter for typo reporting
Public comments should not be the place to point out spelling mistakes.

Versatile dev focusing on media outlets and publishers websites.
Search for a command to run...
Public comments should not be the place to point out spelling mistakes.

Versatile dev focusing on media outlets and publishers websites.
No comments yet. Be the first to comment.
With the upcoming American elections, it's a great time to practice your voting skills and get ready for the polls with this JavaScript-free widget! In this article, we will explore how to create a simple voting widget using Tailwind CSS to customize...
If you’ve ever found yourself knee-deep in PHP, wrestling with WordPress native navigation blocks, and wondering why your menus look like they’ve been lost in translation (literally), you’re not alone! Fear not! Today, we’ll transform that jumbled me...

With the upcoming American elections, it's a great time to practice your voting skills and get ready for the polls with this JavaScript-free widget! In this article, we will explore how to create a simple voting widget using Tailwind CSS to customize...

We decided to use a contextual menu that opens like the Medium highlighter.
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:

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 :
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.
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;
}
}

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 );
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:

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 );
}
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
}

Then we add the event listener on the popover in the function OpenPopover:
popover.addEventListener( 'click', OpenReportPopup );
popover.addEventListener( 'touchend', OpenReportPopup );
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.