Vue.js and Tiptap Menu Bubble: how to build

In this tutorial we will learn how to set up a simple text editor using Vue.js and Tiptap, in particular we’ll be recreating the Menu Bubble example seen here https://tiptap.dev/menu-bubble. We will also add extra functionality to load and save a text file. There are no step by step instructions on how to set up that example in the documentation, so I wanted to write a quick tutorial about it. The example on the tiptap website uses custom svg files for the icons, but fontawesome has some nice icons so let us use those instead. Fontawesome is a very nice icon library, with a ton of free icons to use in your applications.

The repository with the completed project can be found here: https://github.com/tmsdev82/vuejs-tiptap-menu-bubble.

  • Vue.js is a progressive javascript framework for building user interfaces.
  • Tiptap is a renderless extendable rich-text editor for Vue.js

Why

Why recreate a code example that is already on the tiptap website? As mentioned in the introduction there is no step by step set up guide that helps new users. Looking through some comments on the tiptap repository, I noticed that some people have trouble recreating the example. Not only that but, I also ran into obstacles when trying to recreate the example, which took me a while to figure out. In conclusion, we will learn how exactly to set up Vue.js with a tiptap menu bubble using fontawesome icons.

Prerequisites

Creating the project and installing components

First, let us create the project using the Vue CLI. As of the writing of this article tiptap does not work with Vue3, so let’s make sure to select creating a Vue2 project when creating a project with the Vue CLI:

vue create tiptap-menu-bubble

Next we will install all the required packages:

Tiptap packages:

npm install --save tiptap
npm install --save tiptap-extensions

Fontawesome packages:

npm install --save @fortawesome/fontawesome-svg-core
npm install --save @fortawesome/free-solid-svg-icons
npm install --save @fortawesome/vue-fontawesome@2

We are going to use sass styling so, we have install packages for that:

npm install --save sass-loader
npm install --save sass

Initialize use of fontawesome

Let’s open the project in our favorite code editor and open the main.js file in the src directory.

Add the following fontawesome related imports under the other imports:

import { library } from '@fortawesome/fontawesome-svg-core'
import { faBold, faItalic, faCode } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'

Below that we can add the icons to the library, and initialize the FontAwesomeIcon component to enable the use of these icons:

...
library.add(faBold, faItalic, faCode)
Vue.component('font-awesome-icon', FontAwesomeIcon)

Let’s test that everything was installed correctly by going to the HelloWorld.vue file, removing everything between template tags and replacing it with:

<template>
    <font-awesome-icon icon="bold" />
</template>

If we run the app now using:

npm run serve

And navigate to localhost:8080, we should see a B icon on the page.

Icon component

An interesting way to use icons is to create a component that represents the font-awesome-icon and allows individual control of size. Let’s create an Icon.vue file in the components directory and past in this code:

<template>
<font-awesome-icon :icon="name" :class="[`icon-size-${size}`]" />
</template>
<script>
export default {
    props: {
        name: {
            default: "",
        },
        size: {
            default: "normal",
        },
    },
};
</script>
<style lang="scss" scoped>
.icon {
    &-size {
        &-small {
            font-size: 15px;
        }

        &-normal {
            font-size: 18px;
        }

        &-large {
            font-size: 21px;
        }
    }
}
</style>

Now we can change HelloWorld.vue to use this icon component instead of font-awesome-icon directly:

<template>
    <icon name="bold" size="large" />
</template>
<script>
import Icon from './Icon.vue'

export default {
    name: 'HelloWorld',
    components: {
        Icon
    },
    props: {
        msg: String
    }
}
</script>

We should now see a large “B” icon on the page.

Build the menu bubble editor example

We will now implement the actual bubble menu editor. Let us start by cleaning up the default project a little bit.

Starter project clean up

Start by removing the HelloWorld.vue component file. Then we will be removing the reference to HelloWorld.vue from App.vue. Let us remove the Vue logo while we are at it, too. This is what App.vue looks like when we are done:

<template>
<div id="app">

</div>
</template>
<script>
export default {
    name: 'App',
    components: {

    }
}
</script>
<style>
#app {
    font-family: Avenir, Helvetica, Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-align: center;
    color: #2c3e50;
    margin-top: 60px;
}
</style>

Menu Bubble styling

To recreate the menu bubble example we need to copy the scss files from the examples in the tiptap repository https://github.com/ueberdosis/tiptap/tree/main/examples/assets/sass

These styling files will ensure the menu is hidden when no text is selected. It also determines, the size, shape, and colors of the menu, and of the buttons. If we want to make visual changes to the menu, we should do that in these files.

Create a directory named sass under assets to paste the scss files (editor.scss, main.scss, menubar.scss, menububble.scss, and variable.scss) in there.

Now we need to import main.scss into App.vue to be able to use this scss in our project. Add the following to App.vue above the <style> tag that is already there:

<style lang="sass">
@import './assets/sass/main.scss'
</style>

<style>
#app {
    font-family: Avenir, Helvetica, Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-align: center;
    color: #2c3e50;
    margin-top: 60px;
}
</style>

Menu bubble component

Create a file called MenuBubble.vue in the components directory. This is where we will past in the code from the example repo, with some slight adjustments for the icons:

<template>
<div class="editor">
    <editor-menu-bubble :editor="editor" :keep-in-bounds="keepInBounds" v-slot="{ commands, isActive, menu }">
        <div class="menububble" :class="{ 'is-active': menu.isActive }" :style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`">

            <button class="menububble__button" :class="{ 'is-active': isActive.bold() }" @click="commands.bold">
                <icon name="bold" size="small" />
            </button>
            <button class="menububble__button" :class="{ 'is-active': isActive.italic() }" @click="commands.italic">
                <icon name="italic" size="small" />
            </button>
            <button class="menububble__button" :class="{ 'is-active': isActive.code() }" @click="commands.code">
                <icon name="code" size="small" />
            </button>

        </div>
    </editor-menu-bubble>

    <editor-content class="editor__content" :editor="editor" />

</div>
</template>
<script>
import Icon from './Icon'

import {
    Editor,
    EditorContent,
    EditorMenuBubble
} from 'tiptap'
import {
    Blockquote,
    BulletList,
    CodeBlock,
    HardBreak,
    Heading,
    ListItem,
    OrderedList,
    TodoItem,
    TodoList,
    Bold,
    Code,
    Italic,
    Link,
    Strike,
    Underline,
    History,
} from 'tiptap-extensions'

export default {
    components: {
        EditorContent,
        EditorMenuBubble,
        Icon
    },
    props: {

    },
    data() {
        return {
            keepInBounds: true,
            editor: new Editor({
                extensions: [
                    new Blockquote(),
                    new BulletList(),
                    new CodeBlock(),
                    new HardBreak(),
                    new Heading({
                        levels: [1, 2, 3]
                    }),
                    new ListItem(),
                    new OrderedList(),
                    new TodoItem(),
                    new TodoList(),
                    new Link(),
                    new Bold(),
                    new Code(),
                    new Italic(),
                    new Strike(),
                    new Underline(),
                    new History(),
                ],
                content: `
          <h2>
            Menu Bubble
          </h2>
          <p>
            Hey, try to select some text here. There will popup a menu for selecting some inline styles. <em>Remember:</em> you have full control about content and styling of this menu.
          </p>
        `,
            }),
        }
    },
    beforeDestroy() {
        this.editor.destroy()
    },

}
</script>

Let us look at the important sections piece by piece.

HTML

The HTML part of the menu. First the root div in the template.

...
<div class="editor">
    ...
</div>
...

This sets up the text editor look and feel. The elements look like, such as paragraphs, links, tables, background color, font color, width, etc.

Next are the menu bubble elements.

...
<editor-menu-bubble :editor="editor" :keep-in-bounds="keepInBounds" v-slot="{ commands, isActive, menu }">
        <div class="menububble" :class="{ 'is-active': menu.isActive }" :style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`">

            <button class="menububble__button" :class="{ 'is-active': isActive.bold() }" @click="commands.bold">
                <icon name="bold" size="small" />
            </button>
            <button class="menububble__button" :class="{ 'is-active': isActive.italic() }" @click="commands.italic">
                <icon name="italic" size="small" />
            </button>
            <button class="menububble__button" :class="{ 'is-active': isActive.code() }" @click="commands.code">
                <icon name="code" size="small" />
            </button>

        </div>
    </editor-menu-bubble>
...

Here we set up the EditorMenuBubble component and configure some the behavior and look of the menu bubble. Let us look at some of the attributes of the editor-menu-bubble element.

First: :editor takes a editor instance object with which we can configure the text content to be present in the editor. The available elements and buttons, and more.

The next attribute :keep-in-bounds determines if the menu bubble should be kept inside the bounds of the parent element. Setting this to true will make sure the menu does not overlap with other elements.

Finally we have v-slot to inject some parts of the menu buble into the child component. In this case :

  • commands: for the @click behavior of the buttons.
  • isActive: to determine if the menu or a button is active or not.
  • menu: helps inform the child element about positioning and if the menu is active or not, which will determine if the menu is visible or not.

The next div, with class="menububble" is the visual representation of the menu bubble. Inside that div we have the buttons that make up the menu. The buttons use the command object in the @click properties to call the relevant functions for performing operations on the text.

Finally, we have the use of the icon component we created earlier to represent the icons on the menu buttons.

Script

The script part of the editor / menu. First we take a look at the imports.

<script>
import Icon from './Icon'

import {
    Editor,
    EditorContent,
    EditorMenuBubble
} from 'tiptap'
import {
    Blockquote,
    BulletList,
    CodeBlock,
    HardBreak,
    Heading,
    ListItem,
    OrderedList,
    TodoItem,
    TodoList,
    Bold,
    Code,
    Italic,
    Link,
    Strike,
    Underline,
    History,
} from 'tiptap-extensions'

...
</script>

We import Icon to use for the icons on the menu buttons. The next import is for the editor components, we import those from tiptap. The final import line, imports all the buttons from tiptap-extensions. These enable the relevant commands to perform actions on the text. Note that this code was copied directly from the tiptap example repo, and contains more extensions than we actually need for this example.

Finally we set up our component:

export default {
    components: {
        EditorContent,
        EditorMenuBubble,
        Icon
    },
    props: {
        propContent:{
            type: String,
            required: true
        }
    },
    computed : {
        computedContent: function(){
            return this.editor.content = this.propContent;
        }
    },
    data() {
        return {
            keepInBounds: true,
            editor: new Editor({
                extensions: [
                    new Blockquote(),
                    new BulletList(),
                    new CodeBlock(),
                    new HardBreak(),
                    new Heading({
                        levels: [1, 2, 3]
                    }),
                    new ListItem(),
                    new OrderedList(),
                    new TodoItem(),
                    new TodoList(),
                    new Link(),
                    new Bold(),
                    new Code(),
                    new Italic(),
                    new Strike(),
                    new Underline(),
                    new History(),
                ],
                content: this.computedContent
            }),
        }
    },
    beforeDestroy() {
        this.editor.destroy()
    },

}

In the data section of our component we create an instance of the editor object and configure which extensions are to be used. The extensions enable visualization and functionality, like making text bold and making it appear as bold in the editor.

The ``beforeDestroy() function makes sure the editor is cleaned up when the component is destroyed by the system.

Importing the menu into App.vue

Now we can import and use this component in App.vue:

<template>
<div id="app">
    <menu-bubble />
</div>
</template>

<script>
import MenuBubble from './components/MenuBubble.vue'

export default {
    name: 'App',
    components: {
        MenuBubble
    }
}
</script>

<style lang="sass">
@import './assets/sass/main.scss'
</style>

<style>
#app {
    font-family: Avenir, Helvetica, Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-align: center;
    color: #2c3e50;
    margin-top: 60px;
}
</style>

Running the application we should now see text and if we select some of that text a menu should popup. We have achieved our goal of using Vue.js with tiptap to create a menu bubble example. However, the text that we can edit is a hard-coded, that’s not very interesting nor useful. In the next section we will add simple file load and save functionality.

File load and save

To make this example a little bit more useful we’re going to add a component that will allow a user to select a file and load it into the editor. Finally we will add yet another component that will allow a user to save the edited text to a html file.

File load component

Create a file called FileLoader.vue in the components directory, and add the following code:

<template>
  <label class="file-button">
    Load File
    <input type="file" @change="loadTextFromFile">
  </label>
</template>
<script>
export default {
  methods: {
    loadTextFromFile(ev) {
      const file = ev.target.files[0];
      const reader = new FileReader();

      reader.onload = e => this.$emit("load", e.target.result);
      reader.readAsText(file);
    }
  }
};
</script>

Here we set up a file input box and use the FileReader object to load in the contents of the file. When loading of the file is complete it will trigger an even emit:

reader.onload = e => this.$emit("load", e.target.result);

Which will enable us to use the result in the parent of this component. The parent will be MenuBubble.vue in this case. So, let us edit MenuBubble.vue to make use of this FileReader component:

<template>
  <div>
    <div class="editor">
      <editor-menu-bubble
        :editor="editor"
        :keep-in-bounds="keepInBounds"
        v-slot="{ commands, isActive, menu }"
      >
        <div
          class="menububble"
          :class="{ 'is-active': menu.isActive }"
          :style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`"
        >
          <button
            class="menububble__button"
            :class="{ 'is-active': isActive.bold() }"
            @click="commands.bold"
          >
            <icon name="bold" size="small" />
          </button>
          <button
            class="menububble__button"
            :class="{ 'is-active': isActive.italic() }"
            @click="commands.italic"
          >
            <icon name="italic" size="small" />
          </button>
          <button
            class="menububble__button"
            :class="{ 'is-active': isActive.code() }"
            @click="commands.code"
          >
            <icon name="code" size="small" />
          </button>
        </div>
      </editor-menu-bubble>
      <editor-content class="editor__content" :editor="editor" />
    </div>
    <div>
      <file-loader class="file-button" @load="updateEditorContents($event)"></file-loader>
    </div>
  </div>
</template>

<script>
import Icon from './Icon'
import FileLoader from './FileLoader'

import {
    Editor,
    EditorContent,
    EditorMenuBubble,

} from 'tiptap'
import {
    Blockquote,
    BulletList,
    CodeBlock,
    HardBreak,
    Heading,
    ListItem,
    OrderedList,
    TodoItem,
    TodoList,
    Bold,
    Code,
    Italic,
    Link,
    Strike,
    Underline,
    History,
} from 'tiptap-extensions'

export default {
    components: {
        EditorContent,
        EditorMenuBubble,
        Icon,
        FileLoader
    },
    methods: {
        updateEditorContents(newContent){
            this.editor.setContent(newContent);
        },
    },
    data() {
        return {
            keepInBounds: true,
            editor: new Editor({
                extensions: [
                    new Blockquote(),
                    new BulletList(),
                    new CodeBlock(),
                    new HardBreak(),
                    new Heading({
                        levels: [1, 2, 3]
                    }),
                    new ListItem(),
                    new OrderedList(),
                    new TodoItem(),
                    new TodoList(),
                    new Link(),
                    new Bold(),
                    new Code(),
                    new Italic(),
                    new Strike(),
                    new Underline(),
                    new History(),
                ],
            }),
        }
    },
    beforeDestroy() {
        this.editor.destroy()
    },

}
</script>

<style>
.file-button {
  position: relative;
  overflow: hidden;
  display: inline-block;

  border: 2px solid rgb(99, 99, 165);
  color: rgb(99, 99, 165);
  border-radius: 5px;
  padding: 8px 12px;
  cursor: pointer;
}

.file-button:hover {
  color:white;
  background-color: rgb(99, 99, 165);
}

.file-button input {
  position: absolute;
  top: 0;
  left: 0;
  z-index: -1;
  opacity: 0;
}
</style>

We have added a updateEditorContents method for updating the editor content when the file is loaded. We imported FileLoader, and inserted the component below the div tags that hold the editor. Note that there’s also a new root div wrapping all the other content. In the @load attribute for the file-loader element we call updateEditorContents with the result of the $event we are emitting in the FileLoader component. The updateEditorContents method simple calls setContent on the editor instance with the contents of the file: this.editor.setContent(newContent);.

We have also added some styling for the button.

Saving edited text to HTML

To make the full round trip from reading a file from disk, upload to app, write to disk again, we are now going to add export functionality.

We will add a new HTML element below the file-loader element to represent an export button.

...
<div>
    <file-loader class="file-button" @load="updateEditorContents($event)"></file-loader>
    <label class="file-button" @click="exportText">
        Export
    </label>
</div>
...

This button simply has an @click method called exportText. We will also add method called downloadContent, which will set up the download functionality:

methods: {
        updateEditorContents(newContent){
            this.editor.setContent(newContent);
        },
        exportText() {
            const html = this.editor.getHTML();
            console.log("getHTML " + html);
            this.downloadContent(html, ".html", "export.html");
        },
        downloadContent(text, fileType, fileName) {
            const blob = new Blob(, { type: fileType });

            const a = document.createElement('a');
            a.download = fileName;
            a.href = URL.createObjectURL(blob);
            a.dataset.downloadurl = [fileType, a.download, a.href].join(':');
            a.style.display = "none";
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            setTimeout(function() { URL.revokeObjectURL(a.href); }, 1500);
        },
    },

The method exportText simple calls the getHTML method on our editor instance to get the editor contents as HTML. Then this HTML string is passed to the downloadContent function, which will show the user a prompt to save the HTML as file to disk.

Conclusion

We have completed our Vue.js and Tiptap Menu Bubble with fontawesome icons example, similar to the one on the official tiptap website. However, instead of using the svg files from the example repository to represent the icons, we are using fontawesome. Furthermore, we have also added loading and exporting of text, to make the example a little bit more useful.

The completed project can be found in the following repository: https://github.com/tmsdev82/vuejs-tiptap-menu-bubble

In the follow up article: Text highlighting for a Vue.js text editor: how to, we will learn how to implement an extension for tiptap that enables highlighting of selected text.

Leave a Reply

Your email address will not be published. Required fields are marked *