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