Building a Simple Rich Text Editor with Vue 3 and Vanilla JavaScript

Rich text editors are a powerful tool for content creation, enabling users to format text, insert links, images, and more without needing to write HTML. Tools like CKEditor and TinyMCE are popular solutions, but sometimes you might need a lightweight, customizable editor built with frameworks like Vue.js.

In this article, we’ll explore how to build a simple but functional rich text editor using Vue 3 and plain JavaScript. This editor will allow users to format text, insert links, images, lists, and more—giving you flexibility similar to what CKEditor offers, without the additional overhead.

Why Build Your Own Editor?

While CKEditor is feature-rich, it's often more than what’s needed for simpler applications. By building a custom editor, you can:

  1. Control the features and styling to fit your app’s specific needs.

  2. Reduce the bundle size by eliminating unused features.

  3. Learn how text manipulation and selection work in the DOM.

Let’s dive into the step-by-step process of building a minimal rich text editor.

Setting Up Vue 3 with a ContentEditable Div

Vue 3 makes it easy to bind data and handle user input. To create a rich text editor, we’ll use a contenteditable div, which allows users to type and edit HTML content directly. Vue will help us manage the state of the editor and perform actions like applying formatting or inserting images dynamically.

ContentEditable Div as Editor

<div id="editor" contenteditable="true" ref="editor" @input="updateContent">
    <p>This is a sample paragraph. You can format it using the toolbar above.</p>
</div>

Toolbar for Formatting

We’ve built a simple toolbar above the editor with buttons for bold, italic, underline, text alignment, and more. Each button calls the formatText() method, which uses the document.execCommand() function to apply formatting:

formatText(command, value = null) {
  document.execCommand(command, false, value);
  this.updateContent();
}

For example, clicking the "Bold" button runs document.execCommand('bold'), which toggles the bold formatting for the selected text.

Users can also insert images, headings, and links dynamically. For images, we prompt the user for a URL and insert it into the content at the current cursor position:

insertImage() {
  const imageUrl = prompt("Enter image URL");
  if (imageUrl) {
    document.execCommand('insertImage', false, imageUrl);
    this.updateContent();
  }
}

Undo, Redo, and Clear Formatting

To make the editor more user-friendly, we’ve added buttons for undoing and redoing changes, as well as clearing formatting:

undo() {
  document.execCommand('undo');
  this.updateContent();
}

redo() {
  document.execCommand('redo');
  this.updateContent();
}

clearFormatting() {
  document.execCommand('removeFormat', false, null);
  this.updateContent();
}

Here’s the basic setup:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue 3 Rich Text Editor with Toolbar</title>  
  <script src="https://unpkg.com/vue@3"></script>

  <style>
    #editor {
      border: 1px solid #ccc;
      padding: 10px;
      min-height: 150px;
      margin-bottom: 10px;
      white-space: pre-wrap;
      outline: none;
    }

    #output {
      border: 1px solid #ccc;
      padding: 10px;
      min-height: 150px;
      margin-top: 20px;
    }

    .toolbar button {
      margin-right: 5px;
    }
  </style>
</head>
<body>

<div id="app">  
  <div class="toolbar">
    <button @click="formatText('bold')"><b>B</b></button>
    <button @click="formatText('italic')"><i>I</i></button>
    <button @click="formatText('underline')"><u>U</u></button>
    <button @click="formatText('justifyLeft')">Left</button>
    <button @click="formatText('justifyCenter')">Center</button>
    <button @click="formatText('justifyRight')">Right</button>
    <button @click="formatText('insertOrderedList')">OL</button>
    <button @click="formatText('insertUnorderedList')">UL</button>
    <button @click="formatText('formatBlock', 'h1')">H1</button>
    <button @click="formatText('formatBlock', 'h2')">H2</button>
    <button @click="insertImage">Image</button>
    <button @click="undo">Undo</button>
    <button @click="redo">Redo</button>
    <button @click="clearFormatting">Clear</button>
  </div>

  <div id="editor" contenteditable="true" ref="editor" @input="updateContent">
    <p>This is a sample paragraph. You can format it using the toolbar above.</p>
    <p>Another paragraph to test text alignment, lists, and headings.</p>
  </div>  

  <button @click="showUrlInput">Add Link</button>  

  <div v-if="showUrlField">
    <label for="url">Enter URL:</label>
    <input type="text" v-model="linkUrl" placeholder="https://example.com">
    <button @click="applyLink">Apply Link</button>
  </div>

  <div id="output" v-html="editorContentWithLinks"></div>
</div>

<script>
  Vue.createApp({
    data() {
      return {
        editorContent: '',
        showUrlField: false,
        linkUrl: "", 
        selectedText: "",
        selectionRange: null,
      };
    },
    computed: {      
      editorContentWithLinks() {
        return this.editorContent;
      },
    },
    methods: {      
      updateContent() {
        this.editorContent = this.$refs.editor.innerHTML;
      },      
      showUrlInput() {
        const selection = window.getSelection();

        if (selection.rangeCount > 0) {
          const range = selection.getRangeAt(0);

          if (!range.collapsed) {
            this.selectedText = selection.toString();
            this.selectionRange = range;
            this.showUrlField = true; 
          } else {
            alert("Please select some text to add a link.");
          }
        }
      },      
      applyLink() {
        if (!this.linkUrl) {
          alert("Please enter a valid URL.");
          return;
        }

        if (this.selectionRange) {
          const anchor = document.createElement("a");
          anchor.href = this.linkUrl;
          anchor.target = "_blank";
          anchor.textContent = this.selectedText;

          this.selectionRange.deleteContents();
          this.selectionRange.insertNode(anchor);

          this.linkUrl = "";
          this.showUrlField = false;

          this.updateContent();
        }
      },

      formatText(command, value = null) {
        document.execCommand(command, false, value);
        this.updateContent();
      },

      insertImage() {
        const imageUrl = prompt("Enter image URL");
        if (imageUrl) {
          document.execCommand('insertImage', false, imageUrl);
          this.updateContent();
        }
      },

      undo() {
        document.execCommand('undo');
        this.updateContent();
      },

      redo() {
        document.execCommand('redo');
        this.updateContent();
      },

      clearFormatting() {
        document.execCommand('removeFormat', false, null);
        this.updateContent();
      }
    },
    mounted() {
      this.editorContent = this.$refs.editor.innerHTML;
    },
  }).mount("#app");
</script>

</body>
</html>
2
Subscribe to my newsletter

Read articles from Sreenivas Mudragada directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sreenivas Mudragada
Sreenivas Mudragada

I’m a tech lead with over 15 years of experience in the tech industry, In my current role, I’ve led several high-impact projects, overseeing everything from architecture design to implementation and ensuring that our technology strategies align with business goals.