Adding functionality to the image extension in tiptap 2 and Vue 3

Joe Vallender • May 7, 2021

tldr

Introduction

I love the tiptap WYSIWYG editor (based on ProseMirror) and have used v1 in lots of Laravel/Vue projects.

Personally, I find the sweet spot for tiptap is where I want to provide fewer options but make the experience really slick. For example I have a product where a user can edit their own modals/popovers, and rather than pairing down (and potentially fighting) a TinyMCE or a CKEditor, I'd rather build tiptap up in exactly the way I want, even if I have to write a bit more code. It's renderless and integrated with Vue, so you can easily take full control.

This configurability comes at the price of potentially requiring more initial setup, and it doesnt ship with a default UI (but we wanted a renderless editor - so, no problem!).

The original version of tiptap is stable and works well, but it's not compatible with Vue 3 which is what I'm using on all new projects. Version 2 is recently out in beta, and for the limited features I've tested fully, I'd be comfortable using it in production (but keeping a close eye on things).

Working with images

There's an official image extension from tiptap that handles the simplist case of adding an image.

You'll be resonsible for getting an image URL from somewhere and passing it to the setImage() function. In the example from the docs it uses a simple window.prompt() but in reality you're going to upload a file, allow a user to select from their media library etc.

methods: {
    addImage() {
        const url = window.prompt('URL')

        if (url) {
            this.editor.chain().focus().setImage({ src: url }).run()
        }
    },
},

I have a standard pattern I use in Laravel/Vue for uploading an image directly to S3 with a presigned URL and returning the uploaded image URL back to tiptap, I'll share a blog about that in the near future.

So far so good, we can add an image in the editor, and if you check the extension source code on GitHub you can see we could also set the alt and title attributes if we wanted.

In many cases this may be all we need, block level images of the same size (most likely with some CSS like width: 100%; max-width: 100%;). However in that case, if the user wanted a smaller image, they would have to resize it before uploading! Terrible.

Extending the image extension

Fortunately you can create or extend tiptap extensions, so we'll extend the image extension to allow us some control over the image size. You could allow the user to specify the exact width and height of an image, but I think most use cases it will be a nicer experience to choose small/medium/large (like an image in Gmail).

To follow along you can use my example repo:

[email protected]:joevallender/tiptap2-image-example.git
cd tiptap2-image-example
npm install && npm run serve

Or start a clean project with the Vue cli and refer to this post and the repo:

vue create tiptap2-image-example 
# choose: Default (Vue 3 Preview) ([Vue 3] babel, eslint)
cd tiptap2-image-example
npm install -D @tiptap/vue-3 @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-image node-sass sass-loader@~10

I've created a src/extensions directory for our custom extension custom-image.js. Read it in full here or I will step through it below.

We import the original extension and a helper function we'll use later. We use the extend method of the Image extension, name our new extension and make sure to return it's result as the module export.

import Image from '@tiptap/extension-image'
import { mergeAttributes } from '@tiptap/core'

export default Image.extend({
    name: 'custom-image',
    // ..

Extend the original defaultOptions with our new one. We'll use this array to validate size when it's being set.

    defaultOptions: {
        ...Image.options,
        sizes: ['small', 'medium', 'large']
    },

Extend addAtributes to add our new size property and set it's default value. The rendered property lets tiptap know whether or not to add it as an attribute when rendering a HTML tag, it would be true for src/title/alt etc.

    addAttributes() {
        return {
            ...Image.config.addAttributes(),
            size: {
                default: 'small',
                rendered: false
            }
        }
    },

In addCommands I'd originally tried to 'extend' it in the same way as with addAttributes but when I tried that the setImage function lost context for this so I just pasted it in again here...

    addCommands() {
        return {
            setImage: (options) => ({ tr, dispatch }) => {
                const { selection } = tr
                const node = this.type.create(options)

                if (dispatch) {
                    tr.replaceRangeWith(selection.from, selection.to, node)
                }

                return true
            },

... and add a new setAttributes function. It should arguably be called setSize, but when I started tinkering I was thinking of adding a few more options.

For reference, we'll call something along the lines of editor.chain().focus().setAttributes({ size: 'small' }).run() from the editor's buttons.

            setAttributes: (attributes) => ({ tr, dispatch }) => {
                // Check it's a valid size option
                if (!this.options.sizes.includes(attributes.size)) {
                    return false
                }

                const { selection } = tr
                const options = {
                    ...selection.node.attrs,
                    ...attributes
                }
                const node = this.type.create(options)

                if (dispatch) {
                    tr.replaceRangeWith(selection.from, selection.to, node)
                }
            }
        }
    },

You can see that we check for a valid size, and if we've got one proceed to creating a copy of the existing custom-image node without our new size setting, and swapping it in for the old one.

Finally, for the renderHTML function, we access the size attribute of the node and add a custom class with which we can control the image size.

    renderHTML({ node, HTMLAttributes }) {

        const size = node.attrs.size
        HTMLAttributes.class = ' custom-image-' + size

        return [
            'img',
            mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)
        ]
    }
})

Provide a UI to interact with our custom image extension

Now, in your editor component we need to import our custom image extension and tell the editor to use it. We'll also repurpose the tiptap bubble menu to control our custom image size.

Read the full source here, and follow the simplied snippets and explanation below

Import the Editor, EditorContent, and defaultExtension as usual. I used Link in this demo, and it's not included in defaultExtesion, so bring that in along with our CustomImage extension

import { Editor, EditorContent, BubbleMenu } from '@tiptap/vue-3'
import { defaultExtensions } from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import CustomImage from '../extensions/custom-image'
export default {
    components: {
        EditorContent,
        BubbleMenu
    },

Intantiate the editor as usual in mounted, setting the extensions we want it to use. You'll notice I'm using the configure method to set a class that will be added to all CustomImage img tags. It's not strictly necessary but it's a great feature even when you're not developing a custom extension, it can be used on any extension type, and you can add Tailwind classes or whatever you like.

mounted() {
    this.editor = new Editor({
        content: `
            <h1>...</h1>
            <p>...</p>
            <img src="https://source.unsplash.com/8xznAGy4HcY/800x400" />
            `,
        extensions: [
            ...defaultExtensions(),
            Link,
            CustomImage.configure({
                HTMLAttributes: {
                    class: 'custom-image'
                }
            })
        ]
    })
},

For adding an image, I'm using the same <button @click="addImage"> method from the tiptap docs. But I'm lazy to find and paste an image URL to paste in while testing, so I'm prepopulating the prompt with a random image from picsum.photo. I'm using the URL format picsum.photo/id/... and not picsum.photos/w/h because otherwise when tiptap redraws the tag we'll get a different image - that had me confused for a moment at first 🙈

const url = window.prompt(
    'Image URL',
    `https://picsum.photos/id/${Math.floor(Math.random() * 200) + 1}/200/300`
)

Almost there now! We'll use the bubble menu that's used on the docs to bold/underline text to provide our image size settings, and also a convenient link to change image. When I come back to work on this more, I'll probably add a little trash icon to delete the image but in the meantime you can delete the image by selecting it and pressing delete.

We need the v-if="editor" because we'll get an error if the editor isn't set yet. And we need the v-show="editor.isActive('custom-image')" to make sure it only shows up when we've selected one of our custom-image nodes. We use the same pattern with even more specificity editor.isActive('custom-image', { size: 'small' }) to show which size is currently selected.

To set the size, we run editor.chain().focus().setAttributes({ size: 'small' }).run() from our button's click handler. Passing whatever value to the size property that we want to set.

chain tell the editor to expect multiple commands, focus means we're dealing with the currently selected node, setAttributes is our own function we created, and run is... run :)

<bubble-menu 
    class="bubble-menu" 
    :editor="editor"
    v-if="editor"
    v-show="editor.isActive('custom-image')"
>
    <button
        @click="
            editor
                .chain()
                .focus()
                .setAttributes({ size: 'small' })
                .run()
        "
        :class="{
            'is-active': editor.isActive('custom-image', {
                size: 'small'
            })
        }"
    >
        Small
    </button>

    <!-- Other buttons -->

    <button @click="addImage">Change</button>

</bubble-menu>

Take a look at the demo if you haven't already and it should all make sense.

One issue I had, is that occasionally the bubble menu isn't positioned top-center like it should be, and (for me, anyway) is sometimes a bit off to the left. I took a quick look at the bubble menu module and see it uses Tippy (which in turn uses Popper) which is very robust.

I didn't have the time to look into it further but I guess since the standard tiptap use case is for formatting text, Tippy is targeting an element positioned at the cursor/highlighted text... so perhaps when we're using it on a selected block level node that calculation is a little off. If I get to the bottom of it later I'll update the repo.