Using Annotator.js with Ember

In this post we'll build out a feature on an Ember app that allows users to annotate pages and have those annotations be persisted for future visits.

It should look something like this:

To implement these annotations, we'll use the Annotator.js library.

Overview

This application has a Rails API backend and a separate Ember app that serves as the front end. The API has a Readme model and delivers readmes to the Ember app which allows a user to browse the index of readmes and view individual readmes.

We want to give our users the ability to annotate (i.e. highlight and take notes on) these individual readmes. We need these annotations to be persisted to our API, so that when a user next visits the site and views a given readme, their previously created annotations are displayed for them to read, edit and delete.

For the purposes of this walk-through, assume our Rails API has a Readme model and a Readme Controller, namespaced under API::VI and our Ember app has routes set up to display an index of readmes and individual readme show pages.

Let's get started.

Setting Up Annotator.js on the Front-End

Before we worry about persisting annotations and associating them to readmes, let's get the Annotator library up and running in our Ember app. Once we can actually highlight and take notes on certain aspects of a page, we'll move on to persistence on the back-end.

Including Annotator.js In Your Ember App
  • Add the following to your bower.json:
{
  "name": "learn-notebook",
  "dependencies": {
    ...
    "annotator": "https://github.com/openannotation/annotator.git#v1.2.10"
  }
}

Then, run bower install in your terminal.

  • Import the Annotator library into your app by adding the following to your ember-cli-build.js:
module.exports = function(defaults) {  
  var app = new EmberApp(defaults, {
    // Add options here
  });


  app.import('bower_components/annotator/pkg/annotator-full.min.js');

 app.import('bower_components/annotator/pkg/annotator.css');
  return app.toTree();
};

Now that our app includes the Annotator library, let's use it!

Using Annotator with Ember Components

To start using Annotator, now that we've included it in our Ember app, all we need to do is use jQuery to select the element we'd like to be able to annotate and add the Annotator to it:

$("some-element-to-get-annotated").annotator();

And that's it! Sort of. We need to use a little Ember magic to really get this up and running.

A few questions might be on your mind right now. First of all, where does the above code belong? Secondly, how can we be sure the above code will fire only after the element we want to annotate has loaded on the page?

We'll be wrapping up our usage of Annotator in an Ember component. Why a component? I think the Ember docs say it best:

Part of what makes components so useful is that they let you take complete control of a section of the DOM. This allows for direct DOM manipulation, listening and responding to browser events, and using 3rd party JavaScript libraries in your Ember app.

That is exactly what we are looking to do: manipulate the DOM using the Annotator library.

Now that we all agree that a component is the tool for this job, let's set it up.

Setting Up Our Component

Generate your component, we'll call it annotate-it. From the command line, run:

ember generate component annotate-it  

This will generate app/components/annotate-it.js as well as the template for our component, app/templates/components/annotate-it.hbs.

Rendering the Component

Our aim is to have users be able to annotate individual readmes, displayed on the readme's show page. So, we'll call our component on app/templates/readmes/readme.hbs and have our component render the content of a readme on the page.

First, we'll call our component and give it a property readme, set equal to the model available to us on this page:

// app/templates/readmes/readme.hbs

{{annotate-it readme=model}}

Then, in the component's template, we'll use readme, which is equal to the model we passed down into the component, to render the content of the given readme.

// app/templates/components/annotate-it.hbs

<div id="readme">  
  {{readme.content}}
</div>  

Now that our component is on the page, let's attach and activate our Annotator.

Adding Annotator to the Component

Note that the <div> that contains the contents of a given readme has an id of "readme". So, our component needs to use jQuery to select that div and add the annotator to it:

// app/components/annotate-it.js
import Ember from 'ember';

export default Ember.Component.extend({  
  didInsertElement: function() {
   $('#readme').annotator();
  }
});

Note that our annotator gets added to the DOM inside the didInsertElement function. Let's take a step back and talk about this.

Ember Component's didInsertElement Hook

In order to integrate the Annotator library, we need to attach it to an element on the DOM. Attaching the annotator to the <div> with an id of "readme", which contains the content of the readme, is an obvious choice. But, how do we ensure that we attach the annotator to that element only once it has loaded on the page?

Here's where Ember component's didInsertElement hook comes in.

The didInsertElement hook gets triggered when the component has been completely rendered, i.e. inserted into the DOM. By wrapping our annotator invocation within this hook, we ensure that it will fire only once the component, and thus the HTML that contains the element with an id of "readme", is present on the page.

Think of didInsertElement like Ember's "document ready".

Now, we should be able to successfully highlight and add notes to aspects of the page contained in the #readme div.

We're ready to move on to persisting our annotations.

Persisting Annotations on the Back-End

We'll need to do a few things to successfully persist our annotations in a manner that allows them to be re-rendered on the page for future views. First, we'll need to install and configure an Annotator plugin to tell our Ember app to send annotation data to the backend when an annotations is created, updated or deleted. Then, we'll need to build out an Annotation model, controller and serializer into the Rails API that serves as our backend.

The Annotator.js Store Plugin

The Annotator Store plugin serializes annotations and sends them to the server for us. Let's install and configure it.

Installing the Plugin

Install the plugin with the following:

// app/components/annotate-it.js

import Ember from 'ember';

export default Ember.Component.extend({

  didInsertElement: function() {
    $('#readme').annotator().annotator('addPlugin',
        Store', {
          // configuration for the store
     });
  }
});

Here, we use .annotator('addPlugin', <name of plugin>) to add the Store plugin to our Annotator instance. There are a number of additional plugins that Annotator.js provides, and you can check them out here.

Now that we've added the Store plugin, let's configure it. What does that mean? Well, we have to tell Annotator:

  • Where to send the annotation data, i.e. where does your back-end live.
  • What data to send to the server.
  • What data, i.e. which annotations, to load from the server when the page loads.

Annotator does a lot of the heavy lifting for us. What does it take care of exactly? Annotator listens to create, update and delete actions on the DOM and sends payloads to the server accordingly. All we have to do is configure the three things outlined above. Let's do it!

Setting the Server URL

First, let's tell Annotator where to send annotation payloads. In other words, let's tell it where to find our API. Recall that our back-end is a Rails API, running locally (for now, haven't deployed yet, sorry!)

We do so by setting the value of the prefix property, inside our configuration of the Store plugin.

// app/components/annotate-it.js

import Ember from 'ember';

export default Ember.Component.extend({

  didInsertElement: function() {
    $('#readme').annotator().annotator('addPlugin', 'Store', {
      prefix: 'http://localhost:3000/api/v1',
    });
  }
});

Setting Up Our Endpoints

Important Note: Annotator will assume that the endpoints you are sending and requesting data from belong to an Annotation model. The default urls that Annotator will send/request to/from are:

create:  '/annotations',  
update:  '/annotations/:id',  
destroy: '/annotations/:id',  
search:  '/search'  

If we intend to stick with the Annotations model, i.e. give our Rails API an Annotations model and endpoint, we don't need to explicitly configure any of the above urls, except for the search url. The search url is how Annotator will retrieve all the annotations relevant to a query. We'll come back to this in a moment, and take a closer look at how we structure that query and handle it on the back-end. First, let's set up that url:

// app/components/annotate-it.js

import Ember from 'ember';

export default Ember.Component.extend({

  didInsertElement: function() {
    $('#readme').annotator().annotator('addPlugin', 'Store', {
      prefix: 'http://localhost:3000/api/v1',
      urls: {
        search: '/annotations/search'
      }
    });
  }
});

Now, let's configure our Annotator to request annotations from the server when the page loads.

Sending Annotations To and Loading Annotations From the Server

Remember that Annotator handles the page-load (or on our case, component-load) event for us. All we have to do is tell our Annotator instance what to request from the server when this event fires.

We do so with the loadFromSearch property:

// app/components/annotate-it.js

import Ember from 'ember';

export default Ember.Component.extend({

  didInsertElement: function() {
    $('#readme').annotator().annotator('addPlugin', 'Store', {
      prefix: 'http://localhost:3000/api/v1',
      urls: {
        search: '/annotations/search'
      },
      loadFromSearch: {
        // query parameters 
      }
    });
  }
});

loadFromSearch goes hand in hand with the search url we just defined. Together, they will do the following:

  • When the page (or in our case, the component) loads, sent a request for data to http://localhost:3000/api/v1/annotations/search and include in the parameters query information defined in loadFromSearch.
  • Load that data and render the annotations it contains on the DOM.

At this point you might be wondering: what query parameters will we use to load the appropriate annotations? In our case, we want users to be able to annotate individual readmes on that readme's show page (recall that our Rails API and our Ember app both have a Readme model). So, let's associate annotations to readmes and load the annotations associate with the readme whose show page we are on.

On our back-end, in our Rails API, we'll establish the a has-many/belongs-to relationship between readmes and annotations. But first, let's finish setting up Annotator.

By default, Annotator will send the following payload to the server we specific in the prefix property when an annotation is created:

{:quote=>"whatever text the user highlighted",
 :text=>"text of the note user filled out",
 :ranges=>[{"start"=>"/div[1]/h3[6]", "startOffset"=>0, "end"=>"/div[1]/h3[6]", "endOffset"=>8}]}

Here, we have captured the text the user highlighted, the content of the note the user filled out and the "ranges", i.e. the elements on the page that were highlighted. The ranges key points to an array of hashes that describe the location of highlighted elements on the DOM.

This is the data that gets sent to our API when an annotation is created.

However, if we want to associate annotations with the readme that they are made on, then we will have to find a way to include the reamde id in the payload that gets sent to our API.

We'll have to do two things to make this happen:

  • Add the readme id to the payload
  • Give the component access to the readme id.

Including Readme ID in Annotator's Payload to the Server

First, let's make sure our component has access to the id of the readme whose content it is rendering. Remember that the component is being called on the show page of a given readme, but that Ember components are dumb––they are ignorant of the context in which they have been called. Although our annotate-it component is called on the readme show page, it doesn't naturally have access to the model that could be rendered on that show page. That is why we passed in the model to the component when we called it, like this:

{{annotate-it readme=model}}

Now, on the annotate-it.hbs template, we can render the content of the model via readme.content.

So, we gave our component has a readme property, which will be equal to whatever readme object was passed down into it. Therefore, inside app/components/annotate-it.js, we can grab the id of the readme like this: readme.id. Let's take a look:

// app/components/annotate-it.js

import Ember from 'ember';

export default Ember.Component.extend({  
  let readmeId = this.readme.id;
  didInsertElement: function() {
    $('#readme').annotator().annotator('addPlugin', 'Store', {
      prefix: 'http://localhost:3000/api/v1',
      urls: {
        search: '/annotations/search'
      },
      loadFromSearch: {
        // query parameters 
      }
    });
  }
});

Let's take a closer look at the line of code that grabs that ID for us:

let readmeId = this.readme.id;

We refer to the component object itself with this, then we grab the readme property of the component, which exists because we set readme=model when we called the component in the parent template. Then, because the readme property of the component was set equal to model, which is an actual readme object, we can call id on that to get its ID. Phew!

Lastly, we use let so that we can then access the variable readmeId, inside our Annotator below.

Okay, now all we need to do is get Annotator to include this readmeId in the payload it sends to the server. We can do this with the Annotator Store plugin's annotationData property:

// app/components/annotate-it.js

import Ember from 'ember';

export default Ember.Component.extend({  
  let readmeId = this.readme.id;
  didInsertElement: function() {
    $('#readme').annotator().annotator('addPlugin', 'Store', {
      prefix: 'http://localhost:3000/api/v1',
      urls: {
        search: '/annotations/search'
      },
      annotationData: {
        'readme_id': readmeId
      },
      loadFromSearch: {
        // query parameters 
      }
    });
  }
});

The annotationData property allows us to specify any additional information we'd like Annotator to include in the params of the payload it sends to our server.

Now, when a new annotation is created by the user, the payload that gets sent to our API will look like this:

{:readme_id=> "6"
 :quote=>"whatever text the user highlighted",
 :text=>"text of the note user filled out",
 :ranges=>[{"start"=>"/div[1]/h3[6]", "startOffset"=>0, "end"=>"/div[1]/h3[6]", "endOffset"=>8}]}

Now that we are sending the correct data to the server (and we can assume for now that the API is successfully created annotation objects and associating them to readmes), let's tell our loadFromSearch property to only load the annotations associated with the readme be rendered by the component:

// app/components/annotate-it.js

import Ember from 'ember';

export default Ember.Component.extend({  
  let readmeId = this.readme.id;
  didInsertElement: function() {
    $('#readme').annotator().annotator('addPlugin', 'Store', {
      prefix: 'http://localhost:3000/api/v1',
      urls: {
        search: '/annotations/search'
      },
      annotationData: {
        'readme_id': readmeId
      },
      loadFromSearch: {
        'readme_id': readmeId
      }
    });
  }
});

And that's it! Annotator is up and running on the front-end. Now we need to build up the Annotation model, controller and endpoints in our Rails API.

Building Annotations into the API

First things first, we need to create an annotations table in our database and give annotations the necessary attributes. What are the necessary attributes? Well, we need to make sure we can persist the attributes of an annotation that our javascript Annotator sends to our server. Let's refresh our memory as to what that payload looks like:

{readme_id: "6",
 :quote=>"whatever text the user highlighted",
 :text=>"text of the note user filled out",
 :ranges=>[{"start"=>"/div[1]/h3[6]", "startOffset"=>0, "end"=>"/div[1]/h3[6]", "endOffset"=>8}]}

This is the information that we need to persist about a given annotation. The quote, text and ranges attributes are required by the Annotator in order for the annotation to be re-rendered on the page for future visits. The readme_id we choose to add in so that we could associate annotations to readmes. The relationship we want is the has-many/belongs to: a readme has many annotations and an annotation belongs to a readme. So, our annotations table will definitely need a readme_id column.

Common Gotcha: Note that the value of the :rangeskey in our params is an array of hashes in which each key is a string and each value is a string. In order to preserve this exact structure and data type in our database table, we'll need to use Postges' JSON column type.

Let's set up our migration:

Creating the Annotations Table

Generate your migration with rails g migration create_annotations in the command line.

Then define your table like this:

class CreateAnnotations < ActiveRecord::Migration  
  def change
    create_table :annotations do |t|
      t.string :content
      t.integer :readme_id
      t.string :quote
      t.string :text
      t.json :ranges, default: {}
    end
  end
end  

Run your migration and you should be good to go.

Setting up the Models

Dont' forget to add the following macros to your models!

class Readme < ActiveRecord::Base  
  has_many :annotations
end  
class Annotation < ActiveRecord::Base  
  belongs_to :readme
end  

Setting up the Annotation Serializer

In app/serializers create a file annotation_serializer.rb and place the following:

class AnnotationSerializer < ActiveModel::Serializer  
  attributes :id, :text, :quote, :ranges, :readme_id
end  

Defining Our Annotation Endpoints

We need to define the routes for our annotations. In config/routes.rb:

  namespace :api do
    namespace :v1 do
      resources :readmes
      resources :annotations
    end
  end

Note that the annotations endpoints, just like the readmes endpoints, are namespaced under api/v1.

Setting Up the Annotations Controller

In alignment with the namespaced routes, our controllers are nested in the directory app/controllers/api/v1. Here is where I'll place the Annotations Controller:

class Api::V1::AnnotationsController < ApplicationController

  def index
    render json: Annotation.all
  end

  def show
    # some fancy stuff
  end

  def create
    @annotation = Annotation.create(annotation_params)

    # some fancy stuff
  end

  def update
    render json: annotation.update(annotation_params)
  end

  def destroy
    render json: annotation.destroy
  end

  private

  def annotation
    Annotation.find(params[:id])
  end

  def annotation_params
    params.permit(:quote, :text, :readme_id, :ranges => ["start", "startOffset", "end", "endOffset"])
  end

end  

The controller methods above are pretty standard as far as Rails APIs go, with the exception of the #create and the #show methods. Let's take a closer look:

Creating New Annotations

In the #create method above, we create a new annotation in a pretty standard fashion:

@annotation = Annotation.create(annotation_params)

BUT! The version of the Annotator library used here, 1.2.x, is very particular in the response it expects to receive from the server once an annotation has been successfully created. It expects the value of the id key to be a string. I'm fairly certain this has been corrected in the most recent version of Annotator.js, but as that version isn't yet stable, I stuck with using 1.2.x and spent a lot of time figuring out this particular gotcha.

SO, we can't simply send our @annotation object back to the front-end using render json: @annotation, because that object has an id attribute that is set equal to an integer. Instead, we need to take the attributes of the annotation we just created and cast them into a brand new hash that we *can send back to the Annotator on the front-end.

def create  
    @annotation = Annotation.create(annotation_params)
    @annotation.update(uri: request.referrer)
    note = {id: @annotation.id.to_s, readme_id: @annotation.readme_id, quote: @annotation.quote, text: @annotation.text, uri: @annotation.uri, ranges: @annotation.ranges}

    render json: note, status: 200
  end

Retrieving a Set of Annotations

Remember when we set up our Annotator to send a request for annotations to annotations/search with query params of the readmeId? That was fun.

Now we need to set up our Annotations Controller to handle that request. I found that the request that Annotator sends to annotations/search was caught by the show action of the Annotations controller. (If anyone can explain why that is, please comment!). So, our #show method should use the readme_id from the params to grab all of the appropriate annotations:

def show  
    annotations = Annotation.where(reamde_id: annotation_params[:readme_id])
  end

However, the payload that Annotator expects to receive in response to a query to annotations/search, should look like this:

{total: 10, 
 rows: [{readme_id: "6",
 :quote=>"whatever text the user highlighted",
 :text=>"text of the note user filled out",
 :ranges=>[{"start"=>"/div[1]/h3[6]", "startOffset"=>0, "end"=>"/div[1]/h3[6]", "endOffset"=>8}]}, {readme_id: "7",
 :quote=>"whatever text the user highlighted",
 :text=>"text of the note user filled out",
 :ranges=>[{"start"=>"/div[1]/h3[6]", "startOffset"=>0, "end"=>"/div[1]/h3[6]", "endOffset"=>8}]}, more annotations here...]
}

So, we need to build this object and send it back to the Annotator:

def show  
    annotations = Annotation.where(uri: request.referrer)
    results = {total: annotations.size, rows: annotations}
    render json: results
  end

Conclusion

Phew! This post turned into a kind of a monster. Let's sum up some of the pain points we encountered before you go:

  • The Annotator allows us to send additional information of our choosing to the server by using the attributeData property of the Store plugin.
  • To store the ranges attributes of a given annotation, make sure you database table column has a datatype of JSON.
  • This version of Annotator is very particular about the object that gets sent back from the #create method of the Annotations Controller: the id of a newly created annotation must be a string!
  • The #show method of the Annotations Controller catches the request sent by the Annotator to load all of the annotations of a given readme. The Annotator expects a very specific object to be returned, an object that has a key of total, pointing to the total number of annotations found, and a key of rows, pointing to the collection of those annotations.

Happy coding!

subscribe and never miss a post!

Blog Logo

Sophie DeBenedetto

comments powered by Disqus
comments powered by Disqus