Creating a Medium-like text highlighter for typo reporting
Public comments should not be the place to point out spelling mistakes.
Photo by Kelly Sikkema on Unsplash
Choice of UI
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:
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;
}
}
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:
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
}
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.