Combined Mentions with ActionText (Part 1)

October 11, 2022

This past weekend on a side project (RelationKit) I implemented a combined mentions feature to let my users do multiple quick actions while writing text within Trix (ActionText) and the Twitter world thought it'd be a great idea to write a blog post... so here I am 😄.

Setting the scene / What are we building?

We'll build roughly the following:

Getting started

So let's get started with a brand new rails application, and spin up a new app using Esbuild. It may be possible to do this with import maps, but those aren't my cup of tea so I skip straight to ESBuild.
bin/rails new combined-mentions -j esbuild
Then let's install our dependencies we need
cd combined-mentions
bin/rails action_text:install
yarn add tributejs
Now let's scaffold out three models we'll use throughout this post, an Article model, a Post model and lastly a SavedReply model.
bin/rails g scaffold Post title body:rich_text
bin/rails g scaffold Article title body:rich_text
bin/rails g scaffold SavedReply title body:rich_text
Then we need to run migrations
bin/rails db:migrate

There's one last dependency we'll want to install if we want Emoji's in our application (and like, who doesn't? they're fun!). We'll be using a forked version of GitHub's gemoji project.

Why a forked version? I'm glad you asked, because I like the latest emojis and the gem hasn't been updated in two years. Feel free to use the original if you'd like, the code we use will work either way.

Original repo: https://github.com/github/gemoji

In your Gemfile:
gem "gemoji", github: "afomera/gemoji", branch: "emoji-15"
Then bundle your app to get the new gem
bundle install
The last two things I'd like us to do is add some links to the application.html.erb layout so we can navigate between our three models.
Add the following wherever you'd like
<%= link_to "Articles", articles_path %> | <%= link_to "Posts", posts_path %> | <%= link_to "Saved Replies", saved_replies_path %>

Then we want to update our routes.rb file to add a root route to posts#index.  Your routes should look similar to this when you're ready for the next step.
Rails.application.routes.draw do
  root to: "posts#index"

  resources :articles
  resources :posts
  resources :saved_replies
end
Now we can start our Rails server and get to the fun part of the app.
bin/dev

The Stimulus Controller

Let's generate a Stimulus controller called... combined-mentions. We'll apply one target value to it called "field".
bin/rails g stimulus combined-mentions
Now let's update our stimulus controller on connect to initialize a new Tribute instance and on disconnect, we'll detach and cleanup after ourselves.
import { Controller } from "@hotwired/stimulus"
import Tribute from "tributejs"
import Trix from "trix"

// Connects to data-controller="combined-mentions"
export default class extends Controller {
  static targets = [ "field" ]

  connect() {
    this.editor = this.fieldTarget.editor
    this.initializeTribute()
  }

  initializeTribute() {
    this.tribute = new Tribute({
      // More code to come
    });

    // More code to come
  }

  disconnect() {
    this.tribute.detach(this.fieldTarget)
  }
}
Then let's go to the posts/_form.html.erb partial and tell our app to use the new Stimulus controller
<%= form.label :body, style: "display: block" %>
<%= form.rich_text_area :body, data: { controller: "combined-mentions", combined_mentions_target: "field" } %>  
Wherever we'd like to use the combined mentions we just need to add the data-controller and data-combined-mentions-target attributes and Stimulus will take care of the rest for us.
Let's start off by hardcoding some static values and getting Tribute rendering for us on the form.
Inside initializeTribute let's update the code inside the following hardcoded values
    this.tribute = new Tribute({
      collection: [
         // Article mentions, but perhaps this is a user mention for your app.
         {
           trigger: "@",
           allowSpaces: true,
           lookup: "key",
           menuShowMinLength: 1,
           menuItemLength: 10,
           values: [
             { key: "John Doe", value: "johndoe" },
             { key: "Jane Doe", value: "janedoe" }
           ]
         },
         // Saved replies
         {
           trigger: "!",
           allowSpaces: true,
           lookup: "key",
           menuShowMinLength: 1,
           menuItemLength: 10,
           values: [
             { key: "Alex Smith", value: "alexsmith" },
             { key: "John Smith", value: "johnsmit" }
           ]
         }
       ]
    });
    
    this.tribute.attach(this.fieldTarget)
    // more code to come below this

This sets up two different 'collections' in tribute, one we'll use for Articles (@) and one we'll use for saved replies (!). Right now they look like they have pretty similar data, but we'll change that later.

Go ahead and try it out! In your Posts editor try trying @John and you'll see it... works kind of, but it doesn't look great. We're missing the css for Tribute, we should go ahead and add the following to application.css now.
/* Tribute styles */
 .tribute-container {
   border-radius: 4px;
   z-index: 100;
   background-color: #FFFFFF;
   border: 1px solid rgba(0, 0, 0, 0.1);
   box-shadow: 0 0 4px rgba(0, 0, 0, 0.1), 0 5px 20px rgba(0, 0, 0, 0.05);
 }

 .tribute-container ul {
   list-style: none;
   margin: 0;
   padding: 0;
 }

 .tribute-container li {
   background: #fff;
   padding: 0.2em 1em;
   min-width: 15em;
   max-width: 100%;
 }

 .tribute-container .highlight {
   background-color: #0ea5e9;
   color: #fff;
 }

 .tribute-container .highlight span {
   font-weight: bold;
 }
Now trying the app again we should see better styling

Now selecting one by pressing enter won't do anything but paste in the value, which isn't quite what we want.

Let's quickly update the code to finish off the basic functionality. Under this.tribute.attach add the following code replacing the more to come comment.
    this.fieldTarget.addEventListener("tribute-replaced", this.replaced)
    this.tribute.range.pasteHtml = this._pasteHtml.bind(this)
One thing to note is that TributeJS emits a custom tribute-replaced event which we're making use of to call our replaced event.

Next we need to go define the two functions we're calling replaced and _pasteHtml
   replaced(event) {
     let mention = event.detail.item.original

     this.editor.insertHTML(`${mention.key}`)
   }

   _pasteHtml(html, startPos, endPos) {
     let range = this.editor.getSelectedRange()
     let position = range[0]
     let length = endPos - startPos

     this.editor.setSelectedRange([position - length, position])
     this.editor.deleteInDirection("backward")
   }
The replaced function tells TributeJS how to use the selected mention element and what to do with it (in this case, telling Trix to insert the HTML a tag). The _pasteHtml function replaces what was used to trigger tribute with the selected value (ie you typing @john gets removed and replaced with the replaced function's return value).


Recap

So far in this part, we've setup our basic application and models, installed all our dependencies and have a basic demonstration of how TributeJS works. In the next part we'll implement saved replies and emojis.

Part 2 can be found here.

Thanks for reading this far!