Text highlighting for a Vue.js text editor: how to

In this article we will look at a way to implement a Vue.js text editor that will allow highlighting selected text. For this we will be using Vue.js and the tiptap library. We will be building on a project from a previous article about the tiptap library: Vue.js and tiptap menu bubble: how to build.

The completed project can be found in my github repository here.

Prerequisites

We will be using a previous project as a base. So, please download that and install the dependencies if you want to follow along:

npm install

Highlighting extension

To implement a Vue.JS text editor with highlighting using tiptap, we have to write a custom extension. Because, as of the writing of this article, there is no official extension for highlighting text.

Functionality

Our custom highlighting extension will have the following functionality:

  • Mark the text selection with <mark> tags
  • Set a class depending on selection from a drop down
  • Overwrite an existing class on an existing highlight
  • Remove a highlight all together

Implementation

The following code listing contains the full code for the highlight extension:

import { Mark} from 'tiptap'
import { toggleMark, updateMark } from 'tiptap-commands'

export default class HighlightMark extends Mark {
    get name() {
      return "mark";
    }

     get schema() {
      return {
        attrs: {          
          class: {
              default: null
          }          
        },
        parseDOM: [
          {
            tag: "highlight",            
          }
        ],
        toDOM: highlight => [
          "highlight",
          {
            class: highlight.attrs.class,            
          },
          0
        ]
      };
    }

    commands({ type }) {
      return attrs => {
        if(attrs && attrs['class'] === null){
          // mark should be toggled if null
          return toggleMark(type, attrs);
        }
        console.log("attrs input: " + JSON.stringify(attrs));
        return updateMark(type, attrs);
      }
    }

  }

Let us go through it piece by piece.

Imports

import { Mark} from 'tiptap'
import { toggleMark, updateMark } from 'tiptap-commands'

We are importing the class Mark which forms the basis for our extension. A mark in tiptap is used to add extra styling or other infromation to inline content like a strong tag or link. Of course, this is exactly what we want to do with our highlight extension. As opposed to text block level styling like headers, or paragraphs.

Class definition

On the next line, we are importing two functions, toggleMark and updateMark. The toggleMark function toggles a mark on or off. For instance, if the user selects text and clicks the “bold” button, the text toggles to bold, click the “bold” button again and the bold styling will be removed. The other function, updateMark, updates an existing mark with new information.

export default class HighlightMark extends Mark {

As stated before, the Mark class forms the basis for our extension so we are extending it with our Highlight class.

get name() {
      return "highlight";
    }

This function determines the name of the command, that executes the functionality in commands, for this extension.

 get schema() {
      return {
        attrs: {          
          class: {
              default: null
          }          
        },
        parseDOM: [
          {
            tag: "highlight",            
          }
        ],
        toDOM: highlight => [
          "highlight",
          {
            class: highlight.attrs.class,            
          },
          0
        ]
      };
    }

Here define the schema for our highlight extension. This determines what the tags will be named and which attributes we allow on the tags. We are calling the tag highlight. Furthermore, we will set the class attribute on this tag.

  commands({ type }) {
      return attrs => {
        if(!attrs || attrs['class'] === null){
          // if class is null the mark should be removed all together
          return toggleMark(type, attrs);
        }

        // update/create the mark with the new class
        return updateMark(type, attrs);
      }
    }

The code block above is showing the final part of our custom highlight extension, the commands function. With this function, we are defining the logic of the command that can be executed through the menu. There is one condition. With this condition, we are checking to see if we should call toggleMark or updateMark. For instance, if the incoming attributes data is empty or contains an empty class we will call toggleMark, which will result in the removal of the highlight mark. Otherwise, we will call updateMark. With updateMark we are creating new highlight tags with and setting the class according to the input parameter, or update existing highlight tags with a new class, replacing the old one.

We are now well on our way to completing our Vue.JS editor with highlighting capabilities. The next step is implementing UI elements in the menu.

Highlight UI

To use the highlighting extension we are going to add a drop down list of color options to the menu, a fill button to set the highlight, and a erase button to remove the highlight. We will be updating the MenuBubble.vue file with the necessary HTML and JavaScript code.

But, before we get into the changes in MenuBubble.vue, let us import the icons we are going to use for the buttons. The icons are imported in main.js:

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

library.add(faBold, faItalic, faCode, faFillDrip, faEraser)

Vue.config.productionTip = false
Vue.component('font-awesome-icon', FontAwesomeIcon)

new Vue({
  render: h => h(App),
}).$mount('#app')

The updated lines are highlighted.

Now we can take a look at how to update MenuBubble.vue to use our highlight extension. Here is the complete and updated code for MenuBubble.vue:

<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>
          <select v-model="selectedColor">
                <option value="soft-blue">soft blue</option>
                <option value="soft-green">soft green</option>
            </select>
            <button class="menububble__button" @click="commands.highlight({class: selectedColor})">
                <icon name="fill-drip" size="small" />
            </button>
            <button class="menububble__button" @click="commands.highlight({class: null})">
                <icon name="eraser" 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>
        <label class="file-button" @click="exportText">    
            Export
        </label>
    </div>
  </div>
</template>
<script>
import Icon from './Icon'
import FileLoader from './FileLoader'
import HighlightMark from './HighlightMark'

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);
        },
        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);
        },
    },
    data() {
        return {  
            selectedColor: "soft-green",
            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(),
                    new HighlightMark()
                ],
            }),
        }
    },
    beforeDestroy() {
        this.editor.destroy()
    },

}
</script>
<style>
.soft-green {
  background: rgb(180, 233, 180);
}

.soft-blue {
  background: rgb(130, 180, 233);
}

.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 will go over the updated parts, that are highlighted, briefly.

First the HTML:

 <select v-model="selectedColor">
    <option value="soft-blue">soft blue</option>
    <option value="soft-green">soft green</option>
</select>
<button class="menububble__button" @click="commands.highlight({class: selectedColor})">
    <icon name="fill-drip" size="small" />
</button>
<button class="menububble__button" @click="commands.highlight({class: null})">
    <icon name="eraser" size="small" />
</button>

These are the UI elements. We have:

  • A drop down selection list of colors, these correspond to names of styling classes. This value will be sent to the highlight extension to set the class on the highlight tags.
  • A button with a fill-drip icon that calls commands.highlight with a object specifying a value for class: {class: selectedColor}. This will create a new highlight or overwrite an existing one.
  • A button with a eraser icon. This button will call commands.highlight with the class set to null in order to signal the highlight extension that the selected highlight should be removed.

Next the JavaScript:

Import of the HighlightMark extension class:

import HighlightMark from './HighlightMark'

Defining a variable that holds the selected color:

...
data() {
        return {  
            selectedColor: "soft-green",
...

Adding an instance of the HighlightMark extension to the editor’s list of extensions:

editor: new Editor({
    extensions: [
      ...
        new HighlightMark()
    ],
}),

Finally, styling classes to visualize the highlight colors:

<style>
.soft-green {
  background: rgb(180, 233, 180);
}

.soft-blue {
  background: rgb(130, 180, 233);
}
...
</style>

When we run the application now we will be able to select text and highlight the selected text using the menu bubble UI.

Run the application with the following command:

npm run serve

Conclusion

We have implemented highlighting in our Vue.js text editor that is using tiptap, by implementing an extension. Users can now select text, select a color, and highlight the selected text. Users are also able to remove all or parts of the highlight.

While implementing this functionality, we have learned how to implement a simple custom extension to extend the functionality of the tiptap editor.

The completed project can be found in my github repository here.

Leave a Reply

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