TIL: Tiptap Excerpt Extension with Rails

While building Pika’s Stream of posts layout, we had need to add the capability to manage excerpts in the Pika editor. These excerpts would be used to show a small portion of your post in a post stream while offering a “continue reading” link for readers to click to read the rest of your post. To add this capability we had to dig into extending the base open source library for our editor, Tiptap.

First the Tiptap part

Below is the full code of the extension. Primarily the extension detects if the user types {{ excerpt }}, {{ more }}, or WordPress’s <!--more--> and replaces that text with:

<div data-type="excerpt" class="post-excerpt" contenteditable="false" draggable="true">↑ Excerpt ↑</div>

With that HTML, we use CSS to style things like so:

Pika blog post with an excerpt indicated within the editor by a dashed line between the excerpted text and the rest of the blog post

This extension is smart enough to know if an excerpt already exists in the editor, in that case disallowing another excerpt being created.

The extension:

import { Node, InputRule, nodeInputRule, mergeAttributes } from '@tiptap/core'

/**
* Look for on a line by itself, with all the whitespace permutations
*/

export const excerptInputRegex = /^\s*{{\s*excerpt\s*}}\s*$/i

/**
* Look for on a line by itself, with all the whitespace permutations
*/

export const moreInputRegex = /^\s*{{\s*more\s*}}\s*$/i

/**
* Look for classic WordPress <!--more--> tag
*/

export const wpMoreInputRegex = /^\s*<*![-—]+\s*more\s*[-—]+>\s*$/i

const excerptText = '↑ Excerpt ↑'

/**
* Used to detect if an excerpt already exists in the editor
*/

const hasExistingExcerpt = (state) => {
let hasExcerpt = false
state.doc.descendants(node => {
if (node.type.name === 'excerpt') {
hasExcerpt = true
return false // stop traversing
}
})
return hasExcerpt
}

/**
* Disable excerpt button in toolbar if excerpt already exists in
* the editor. Note that we use Tiptap with the Rhino editor for
* Rails, which explains the rhino-editor selectors. Rhino:
* https://rhino-editor.vercel.app/
*/

const setExcerptButtonState = (editor) => {
const button = editor.view.dom.closest('rhino-editor').querySelector('rhino-editor button[data-excerpt]')
if (button) {
button.classList.toggle('toolbar__button--disable', hasExistingExcerpt(editor.state))
button.disabled = hasExistingExcerpt(editor.state)
}
}

/**
* This custom InputRule allows us to make a singleton excerpt node
* that short-circuits if an excerpt node already exists.
*/

export function excerptInputRule(config) {
return new InputRule({
find: config.find,
handler: ({ state, range, match }) => {
if (hasExistingExcerpt(state)) {
return
}

const delegate = nodeInputRule({
find: config.find,
type: config.type,
})

delegate.handler({ state, range, match })
},
})
}

export const Excerpt = Node.create({
name: 'excerpt',
group: 'block',
content: 'inline+',
inline: false,
isolating: true,
atom: true,
draggable: true,
selectable: true,

onCreate() {
setExcerptButtonState(this.editor)
},

onUpdate() {
setExcerptButtonState(this.editor)
},

parseHTML () {
return [{ tag: 'div[data-type="excerpt"]' }]
},

renderHTML ({ HTMLAttributes }) {
return ['div', mergeAttributes({ 'data-type': 'excerpt', class: 'post-excerpt' }, HTMLAttributes), excerptText]
},

/**
* Add insertExcerpt command that we can call from our custom
* toolbar buttons. This command checks for an existing excerpt
* before inserting a new one.
*/

addCommands() {
return {
insertExcerpt: () => ({ state, commands }) => {
if (hasExistingExcerpt(state)) {
return false
}

return commands.insertContent({
type: this.name,
content: [{
type: 'text',
text: excerptText
}]
})
},
}
},

/**
* Set up various detection for {{ excerpt }} etc text.
*/

addInputRules() {
return [
excerptInputRule({
find: excerptInputRegex,
type: this.type,
}),
excerptInputRule({
find: moreInputRegex,
type: this.type,
}),
excerptInputRule({
find: wpMoreInputRegex,
type: this.type,
}),
]
},

})

Now for the Rails part

This will obviously need modified depending on your Tiptap environment. In our case, using the Rhino editor with Rails, here’s what we do…

app/javascript/controllers/extensions/excerpt.js is where our extensions directory lives. We use importmaps to manage our JavaScript, so we need to pin our extensions directory there:

pin_all_from "app/javascript/extensions", under: "extensions"

We have a Stimulus controller for all of our Rhino enhancements. We need to import our extension there:

import { Excerpt } from "extensions/excerpt"

To add the extension, we do this in the function that we use to do all of our Rhino initialization (we pass the Rhino editor into this function):

initializeEditor(rhino) {
// snip
rhino.addExtensions(Excerpt)
// snip
}

And we add one function, which we will later call with our new Rhino toolbar button. This function calls the insertExcerpt command that we defined in our extension.

insertExcerpt() {
this.element.editor.chain().focus().insertExcerpt().run()
}

And finally here’s the button we add to our Rhino toolbar. Notice the data-action="click->rhino#insertExcerpt", which is calling the function above:

<button slot="after-increase-indentation-button" class="rhino-toolbar-button toolbar__button--excerpt" type="button" data-role-tooltip="rhino-insert-excerpt" data-action="click->rhino#insertExcerpt" data-excerpt data-role="toolbar-item" tabindex="-1">
<role-tooltip class="toolbar__tooltip" id="rhino-insert-excerpt" part="toolbar__tooltip toolbar__tooltip--create-excerpt" exportparts="base:toolbar__tooltip__base, arrow:toolbar__tooltip__arrow">
Create Excerpt
</role-tooltip>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" fill="currentColor" part="toolbar__icon" viewBox="0 0 24 24" width="24" height="24" style="pointer-events: none;">
<path d="SNIP"/>
</svg>
</button>

This is by no means a drop-in extension, but hopefully it helps someone else who is wanting to add this excerpt functionality to their Tiptap editor.