Creating a Medium-like text highlighter to report typo
Tags: webdev, javascript
Published: 2022-11-20 09:52:39

**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](https://blog.medium.com/introducing-highlights-a4df69e8ed43).

[![Medium example](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kql433fnt0ajuufkk39a.png)](https://blog.medium.com/introducing-highlights-a4df69e8ed43)

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](https://nangka.fr/img/hashnode-typo.gif)

## 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**](https://github.com/psmb/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**](https://github.com/yabwe/medium-editor) (a clone of medium.com inline editor toolbar): a bit too much as we just want the highlight/select feature
* [**jquery.textSelect**](https://github.com/JamesMGreene/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**](https://codecanyon.net/item/highlighter-pro-a-mediumcominspired-text-highlighting-and-inline-commenting-tool-for-wordpress/20743682) (WordPress extension): too heavy for what we need
* 👍️ [**TinyQ**](https://github.com/aruminant/tinyq) (zero dependency JS lib)
* 👍️ [**Highlight share**](https://github.com/anythingcodes/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.

```javascript
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:
```javascript
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 ) );
}
```

```scss
.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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n7vbi7sv3nrg4wzdmm4d.png)

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`](https://developer.mozilla.org/en-US/docs/Web/API/Document/selectionchange_event) and we also worked with `mouseup` and `touchend` to ensure compatibility for older browsers.

```javascript
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:

```javascript
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:
```javascript
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1nkt2vi8eauub2pqb330.png)

To avoid text selection on the whole page but limit it to the `highlightableElements`, we could modify the `TogglePopover` function (using jQuery helper function):
```javascript
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](https://jsmodal.com/)*.

```javascript
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/x68e687urnkp6x2altqx.png)

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

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

## 🚀️ What's next?

Now we have to style the content of the popup and 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.