Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Docs: how we added mention support #113

Open
nathancolgate opened this issue Sep 12, 2023 · 1 comment
Open

Docs: how we added mention support #113

nathancolgate opened this issue Sep 12, 2023 · 1 comment

Comments

@nathancolgate
Copy link
Contributor

Leaving this here as a rough start to writing the docs for adding mention support:

We are going to be @mentioning Users, who have names.

The Rails Stuff

Let's do the railsy stuff first, and update our User model to be quack like ActionText::Attachments

# app/models/user.rb
class User < ApplicationRecord
  # Used when editing
  def to_trix_content_attachment_partial_path
    "user_mentions/trix_content_attachment"
  end

  # Used when displaying
  def to_attachable_partial_path
    "user_mentions/attachable"
  end

  # A custom content type for easy querying
  def attachable_content_type
    "application/vnd.active_record.user"
  end
end

Those two partials are used by action text to render the attachment in two places: First within the editor:

# app/views/user_mentions/_trix_content_attachment.html.erb
<%= user.name %>

Second, when rendered within the application in our views. In this case: we want to display a link to the user (but this could be whatever you want)

# app/views/user_mentions/_attachable.html.erb
<%= link_to user.name, user_path(user) %>

We also monkey patch ActionText to export these attachments as span tags, instead of figure tags. This is because TipTap really wants to convert figures into their own blocks, and not display them inline (which we really want for our mentions).

# config/initializers/action_text.rb
module ActionText
  class TrixAttachment
    TAG_NAME = "span"
  end
end

Now let's build the endpoint for for searching users. Routes first:

# config/routes.rb
resources :user_mentions, only: [:index]

And the controller:

# app/controllers/user_mentions_controller.rb
class UserMentionsController < ApplicationController
  def index
    # Use your own search logic here, but something like
    @q = User.ransack({name_cont: params[:query]})
    @users = @q.result.distinct.limit(5)
    respond_to do |format|
      format.json
    end
  end
end

With views that renders the JSON:

# app/views/user_mentions/index.json.jbuilder
json.array! @users, partial: "user_mentions/user_mention", as: :user

With the three important bits that our attachments care about:

# app/views/user_mentions/_user_mention.json.jbuilder
json.content user.name
json.sgid user.attachable_sgid
json.contentType user.attachable_content_type

Storing the Attachments

We are going to store the attachments in the database. Create a model that looks like:

# app/models/action_text_user_mention.rb
class ActionTextUserMention < ApplicationRecord
  belongs_to :action_text_rich_text, class_name: "ActionText::RichText"
  belongs_to :user
end

And then we hook into ActionText to maintain these records everytime an ActionText is created/updated:

# config/initializers/action_text_user_mentions.rb
ActiveSupport.on_load(:action_text_rich_text) do
  ActionText::RichText.class_eval do
    has_many :user_mentions, class_name: "ActionTextUserMention", foreign_key: :action_text_rich_text_id, dependent: :destroy
    has_many :users, through: :user_mentions

    before_save do
      self.users = body.attachables.grep(User).uniq if body.present?
    end
  end
end

And finally, a little CSS:

rhino-editor span.mention {
  border: 1px solid #000;
  border-radius: 0.4rem;
  padding: 0.1rem 0.3rem;
  box-decoration-break: clone;
}

The JavaScript

We didn't use Vue or React, but rather a custom LitElements. Make sure you enable decorators in your build environment

Our final view component looks like:

// app/javascript/rhino-editor/elements/MentionList.js
// This mention list takes an array of suggested 
// items that represent action text attachments
// with the properties of content/contentType/sgid
// and renders a tippy popover that the user can
// navigate and select.
//
// Selecting an item calls the command function
// and passes those three properties back.

import { html, css, LitElement } from "lit"
import {customElement} from 'lit/decorators/custom-element.js';
import {property} from 'lit/decorators/property.js';

@customElement('mention-list')
class MentionList extends LitElement {
  @property({ type: Array }) items = [];
  @property({ type: Number }) selectedIndex = 0;
  @property({ type: Function }) command;
  
  static styles = css`
    .suggested-items {
      padding: 0.2rem;
      position: relative;
      border-radius: 0.5rem;
      background: #fff;
      color: rgba(0, 0, 0, 0.8);
      overflow: hidden;
      font-size: 0.9rem;
      box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1);
    }
    .suggested-item {
      display: block;
      margin: 0;
      width: 100%;
      text-align: left;
      background: transparent;
      border-radius: 0.4rem;
      border: 1px solid transparent;
      padding: 0.2rem 0.4rem;
    }
    .suggested-item.is-selected {
      border-color: #000;
    }
  `;

  render() {
    return html`
      <div class="suggested-items">
        ${this.items.length > 0 ? 
          this.items.map((item, index) => html`
            <button
              class="suggested-item ${index === this.selectedIndex ? 'is-selected' : ''}"
              @click=${() => this.selectItem(index)}
            >
              ${item.content}
            </button>`
          ) : html`<div class="suggested-item">No result</div>`}
      </div>
    `;
  }

  onKeyDown({event}) {
    if (event.key === 'ArrowUp') {
      this.upHandler()
      return true
    }

    if (event.key === 'ArrowDown') {
      this.downHandler()
      return true
    }

    if (event.key === 'Enter') {
      this.enterHandler()
      return true
    }

    return false
  }

  upHandler() {
    this.selectedIndex = ((this.selectedIndex + this.items.length) - 1) % this.items.length
  }

  downHandler() {
    this.selectedIndex = (this.selectedIndex + 1) % this.items.length
  }

  enterHandler() {
    this.selectItem(this.selectedIndex)
  }

  selectItem(index) {
    const item = this.items[index]
    if (item) {
      this.command({
        sgid: item.sgid,
        content: item.content,
        contentType: item.contentType
      })
    }
  }
}

export default MentionList;

We extend the Mention extension to integrate with Action Text Attachments.

// app/javascript/rhino-editor/extensions/ActionTextAttachmentMention.js
import Mention from '@tiptap/extension-mention'
import { mergeAttributes } from '@tiptap/core'
import tippy from 'tippy.js'
import '../elements/MentionList.js';

// https://github.com/KonnorRogers/rhino-editor/pull/111
// import { findAttribute } from "rhino-editor/exports/extensions/find-attribute.js";
function findAttribute(element, attribute) {
  const attr = element
    .closest("action-text-attachment")
    ?.getAttribute(attribute);
  if (attr) return attr;

  const attrs = element
    .closest("[data-trix-attachment]")
    ?.getAttribute("data-trix-attachment");
  if (!attrs) return null;

  return JSON.parse(attrs)[attribute];
}

const ActionTextAttachmentMention = Mention.extend({
  name: 'ActiveRecordMention',
  addOptions() {
    return {
      HTMLAttributes: {},
      renderLabel({ options, node }) {
        return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
      },
      attachmentContentType: "application/octet-stream",
      suggestion: {
        render: () => {
          let component
          let popup
      
          return {
            onStart: props => {
              component = document.createElement('mention-list');
              component.items = props.items;
              component.command = props.command;
      
              if (!props.clientRect) {
                return;
              }
              popup = tippy('body', {
                getReferenceClientRect: props.clientRect,
                appendTo: () => document.body,
                content: component,
                showOnCreate: true,
                interactive: true,
                trigger: 'manual',
                placement: 'bottom-start',
              });
            },
      
            onUpdate(props) {
              component.items = props.items;
              component.command = props.command;
              if (!props.clientRect) {
                return
              }
      
              popup[0].setProps({
                getReferenceClientRect: props.clientRect,
              })
            },
      
            onKeyDown(props) {
              if (props.event.key === 'Escape') {
                popup[0].hide()
                return true
              }
              return component.onKeyDown(props)
            },
      
            onExit() {
              popup[0].destroy();
            },
          }
        },
        command: ({ editor, range, props }) => {
          const nodeAfter = editor.view.state.selection.$to.nodeAfter
          const overrideSpace = nodeAfter?.text?.startsWith(' ')

          if (overrideSpace) {
            range.to += 1
          }

          editor
            .chain()
            .focus()
            .insertContentAt(range, [
              {
                type: this.name,
                attrs: props,
              },
              {
                type: 'text',
                text: ' ',
              },
            ])
            .run()

          window.getSelection()?.collapseToEnd()
        },
        allow: ({ state, range }) => {
          const $from = state.doc.resolve(range.from)
          const type = state.schema.nodes[this.name]
          const allow = !!$from.parent.type.contentMatch.matchType(type)

          return allow
        },
      },
    }
  },
  parseHTML() {
    return [
      {
        tag: `[data-trix-attachment]`,
        getAttrs: (element) => {
          if (findAttribute(element, "contentType") == this.options.attachmentContentType) {
            return true;
          }
          return false;
        },
      },
    ]
  },
  addAttributes() {
    return {
      sgid: {
        default: null,
        parseHTML: (element) => {
          return (
            findAttribute(element, "sgid")
          );
        },
        renderHTML: attributes => {
          return {}
        },
      },
      content: {
        default: null,
        parseHTML: (element) => {
          return (
            findAttribute(element, "content").trim()
          );
        },
        renderHTML: attributes => {
          return {}
        },
      },
      contentType: {
        default: this.options.attachmentContentType,
        parseHTML: (element) => {
          return (
            findAttribute(element, "contentType")
          );
        },
        renderHTML: attributes => {
          return {}
        },
      },
    }
  },
  renderHTML({ node, HTMLAttributes }) {
    const trixAttributes = {
      sgid: node.attrs.sgid,
      content: node.attrs.content,
      contentType: node.attrs.contentType
    }
    const label = [
      'span',
      {class: "mention"},
      `#${node.attrs.content}`,
    ]
    return [
      'span',
      mergeAttributes({"data-trix-attachment": JSON.stringify(trixAttributes)}, this.options.HTMLAttributes, HTMLAttributes),
      label,
    ]
  },
})

export default ActionTextAttachmentMention;

You can use this extension directly if you want. Or (in our case) if you need to have more than one mention functionality, you can extend it. For example, you could @mention users, and #mention tags:

// app/javascript/rhino-editor/extensions/UserMention.js
import ActionTextAttachmentMention from './ActionTextAttachmentMention'
import { PluginKey } from '@tiptap/pm/state'
import Suggestion from '@tiptap/suggestion'

// When using multiple mention extensions at the same time
// you must make two things unique:
// * The name of the extension
// * The pluginKey for the Suggestion plugin
// 
// We do that here.
// Everything else is configured at a higher level.

const UserMention = ActionTextAttachmentMention.extend({
  name: 'UserMention',
  addProseMirrorPlugins() {
    return [
      Suggestion({
        editor: this.editor,
        pluginKey: new PluginKey('UserMentionSuggestion'),
        ...this.options.suggestion,
      }),
    ]
  },
})

export default UserMention

All that's left is to configure the extension with your editor:

import UserMention from './extensions/UserMention.js'

function extendRhinoEditor (event) {
  const rhinoEditor = event.target
  
  if (rhinoEditor == null) return

  rhinoEditor.addExtensions(UserMention.configure({
    suggestion: {
      char: '@',
      items: async ({ query }) => {
        const response = await fetch(`/user_mentions.json?query=${query}`);
        const data = await response.json();
        return data;
      },
    },
    attachmentContentType: "application/vnd.active_record.user"
  }))

  rhinoEditor.starterKitOptions = {
    ...rhinoEditor.starterKitOptions,
    // codeBlock: false,
    rhinoGallery: false,
    rhinoAttachment: false,
    rhinoFigcaption: false,
    rhinoImage: false,
    rhinoStrike: false,
    // rhinoFocus: false,
    rhinoLink: false,
    rhinoPlaceholder: false,
    // rhinoPasteEvent: false,
  }

  rhinoEditor.rebuildEditor()
}

document.addEventListener("rhino-before-initialize", extendRhinoEditor)

Note: We had to disable some of Rhino's default extensions due to #112

@lylo
Copy link

lylo commented Sep 13, 2023

Wow, this is awesome @nathancolgate 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Development

No branches or pull requests

2 participants