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
- Vue.js knowledge.
- Vue CLI tool installed, find instructions here.
- Some tiptap knowledge is helpful. Tiptap documentation.
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.