Gebna

Build Medium like highlight and inline comment using React and Firebase

Written by Bahaa Zidan

A few days ago, I sat out to achieve the same inline commenting and highlight functionality that is found on Medium.com.

Gif of Medium's highlight function

Note that this isn’t a full tutorial. It’s more of a brief walkthrough of a side project I did.

Join me as I walk you through how I did it and what are the lessons learned from this weekend project. To be as concise as possible, I won’t be detailing every single line of code or every component of this project. You’re encouraged to read the full project on the repo.

Things I assume you’re familiar with

What we’re building

We’re building a blog where anyone can create posts, edit posts, create comments, and most importantly create comments on a certain quote from the post. We’re not going to tackle authentication or authorization in this project. We’re not going to polish the UI either.

The data model

For this is a simple project that shouldn’t take more than a few days, I tried to model the problem into the simplest possible thing. I’ll be discussing different approaches that might be better and less limited towards the end. Also please note that while I’m using FireStore here, this model can be achieved in practically any relational or non-relational database out there.

The data model

I started a posts collection. A post document stores a slug (which acts as an ID), a title, and a date:

The post document

It also has 3 sub-collections: comments, quotes, and versions. A version stores the actual textual content of the post, it has an ID, and a date. That way it’s easy to support versioning and rollback in the future. But it is also very important that our quotes are linked to a certain versionID to handle the cases where a quote has been taken from an outdated version of the post. Also for convenience, a comment may include a DocumentReference to a quote.

Versions sample: Versions

Comments sample: Comments

As for quotes. They include include metadata about the location of the quote in the post. These fields will be more meaningful when we start tackling the UI portion of this project. But for now here’s a sample data of the quote:

Quotes

API

Given the scale and the time I had to finish this project, I chose not to make a dedicated backend server. And have the client query FireStore directly. But to avoid mess, I had all the code that queried FireStore in one file: posts.ts. If this is to be a production-grade code, I’d make a dedicated GraphQL or REST server that abstracted the implementation from the clients. Here’s a peek at a few methods in that file:

export async function getPosts() {
  const postsCollection = collection(db, "posts");
  const postsSnapshot = await getDocs(postsCollection);
  const posts = postsSnapshot.docs.map((doc) => {
    const data = doc.data();
    return {
      title: data.title,
      slug: data.slug,
      date: data.date.toDate().toDateString(),
    };
  });
 
  return posts;
}
 
export async function createPost(post: {
  slug: string;
  title: string;
  content: string;
}) {
  const postDocumentRef = doc(db, `posts/${post.slug}`);
  await setDoc(postDocumentRef, {
    slug: post.slug,
    title: post.title,
    date: Timestamp.fromDate(new Date()),
  });
 
  const versionsCollectionRef = collection(db, `posts/${post.slug}/versions`);
  await addDoc(versionsCollectionRef, {
    content: post.content,
    date: Timestamp.fromDate(new Date()),
  });
}

The client

I’m going to use Next.js’s blog example as a starting point. This gives me the blog home, about, and post pages. I then changed the code so that the posts are queried from FireStore and not from a local MDX file (see posts/index.tsx). I also added two pages: create-post, and edit-post.

All of that gave me good foundation to introduce the comment feature. In contrast to the posts which are rendered server-side, the comments are rendered client-side. For the comments, I added all the state management code in the [slug].tsx page.

Inline comments

To support inline-commenting we need to introduce two things: the Range object and XPath.

Inline comments

When the user highlights part of the text of the post. We can get an object that contains information about that selection:

const selection = window.getSelection();
const range = selection.getRangeAt(0);

The Range object contains information about the text selected, the html parent node of that text, and the start and end offsets of the selected text within it’s text node. We’ll store the start and end offsets in FireStore to be able to retrieve the highlight in the future. But how can we store the html node of that selection ? We can’t. That’s where XPath becomes useful. XPath stands for XML Path Language. It uses a non-XML syntax to provide a flexible way of addressing (pointing to) different parts of an XML document. And since HTML is XML, we can store an XPath expression in the database instead. That would make the quote data sample shown before sensible:

Range

Note that we also store the postVersionID that the quote is referencing. That is to allow us to easily handle user edits.

Also note that for a medium-like selection popup, we used a library called react-highlight-pop. It basically listens to the mouseUp event, gets the window.Selection() object, and renders a popup at it’s position.

Click on highlight-pop to scroll

The last piece of the puzzle here is how can we allow the user to click on a highlight/quote in a comment and scroll to it’s position in the post.

Highlight in action

Since we have the XPath and Range information. We can just wrap a quote in a <mark> tag and scroll to it.

const handleQuoteClick = (quote) => {
  unwrap(currentMarkElement);
  try {
    const range = toRange(
      quote.startXpath,
      quote.startOffset,
      quote.endXpath,
      quote.endOffset,
      document,
    );
 
    const markElement = document.createElement("mark");
    markElement.style.backgroundColor = "rgba(52, 211, 153, 1)";
 
    range.surroundContents(markElement);
    markElement.scrollIntoView();
 
    setCurrentMarkElement(markElement);
  } catch (e) {
    console.error(e);
  }
};

Because we’re modifying the DOM directly the XPaths in the other comments won’t work if the user clicks on other comments. That’s why we keep track of the created mark element. And delete it every time the user clicks. That would limit us to only displaying one highlight at a time. But for the sake of prototyping, it works 🎉🎉🎉

Lessons learned

Having finished this project. There are a few things I would’ve done differently. The most important one. Is how I stored and retrieved the quotes. In this version we store metadata about the Range and the XPath of both the start and end containers. This works but it’s very brittle and limited. And honestly I don’t like storing client-specific concepts in the backend.

A better approach would’ve been to process the post’s content on the server. And split each post into an array of paragraphs. Give each paragraph an ID. And assign the quote to a paragraph ID instead of a specific position in the html document. That would be much less brittle in my opinion but it will require some processing in the server side.

Also I’m storing the full content of the post version every time the user edits. This is unnecessary. Like git, we can just store the deltas between every subsequent version and construct the full post on the fly in the server-side.

Overall, I learned so much building this thing. I hope I was able to relay some of what I learned to you. This isn’t a full tutorial it’s more of a brief walkthrough of a side project I did.