{
  "$type": "site.standard.document",
  "description": "One way you might build your own Vue rich text component.",
  "path": "/blog/building-your-own-vue-rich-text-component",
  "publishedAt": "2020-03-01T13:30:00.000Z",
  "site": "at://did:plc:jbeaa5kdaladzwq3r7f5xgwe/site.standard.publication/3gtfwwbnks225",
  "tags": [
    "vue",
    "components",
    "rich text"
  ],
  "textContent": "If you're like me, when you're building a web application, you'll often come to moment where you need new functionality to enable the feature you're delivering.\n\nFor example, you might need touch events for a carousel, or a quick tooltip, or to be notified when an element changes size. There are great libraries to do all of these things. But without noticing it, you might find your bundle size is increasing disproportionate to the functionality you need. So, for example, if you're using hammerjs [https://hammerjs.github.io/] just to enable mobile touch events - don't! There's a great API [https://developer.mozilla.org/en-US/docs/Web/API/Touch_events] that is just as simple to engage with.\n\n\nTHE PROBLEM: HEAVY RICH-TEXT COMPONENTS\n\nHowever, this really came alive for me recently. As part of our functionality with Parent Scheme [https://parentscheme.com], we allow users to save answers to coaching questions embedded throughout the site. And at some point, rather than using a basic autosizing textarea, we decided to allow rich text, and grabbed the fantastic tiptap [https://github.com/scrumpy/tiptap], a beautifully designed, renderless rich-text editor for Vue.js that wraps Prosemirror [https://prosemirror.net/].\n\nIt worked fantastically well, and we were able to roll out a great user experience immediately. But we soon noticed that it added extra weight to our webpack bundle. How much? 359kB of parsed JS!\n\nThat might have been worth it for an app more centred around the editor experience, but it wasn't for us. So we started looking for alternatives.\n\n\nPELL - A TINY RICH TEXT EDITOR\n\nThere are other libraries, like Quill, Squire, and so on. Most have a pretty heavy dependency chain, and those that are lighter tend not to have the functionality we wanted - such as the ability to use Markdown shortcuts.\n\nSo rather than aim for minor improvements, why not start as simple as possible and build in required functionality?\n\nPell [https://github.com/jaredreich/pell], for example, is just 3.54kB minified - just 1% of our previous bundle size with tiptap.\n\nIt renders something like this:\n\nVue makes it very easy to pull in a library with a custom wrapper component, and there are packages that do that with Pell. But, to be honest, that's probably the wrong thing to do. The base library is so simple that it's a great foundation for building your own rich text editor Vue component. And I wanted to make sure we supported Markdown shortcuts — automatically creating bulleted lists after typing *, for example. So this is a good example of when it's best to re-implement functionality directly in Vue.\n\n\nBUILDING OUR OWN RICH TEXT EDITOR\n\nSo, how might you build your own Vue rich text component using the techniques Pell does?\n\nThe magic takes place using the HTML element attribute contenteditable (see MDN [https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/contentEditable]). Add this attribute to an element and the browser provides an API to edit raw HTML. As long as we're happy to ingest HTML output, this is perfect for a lightweight rich text editor experience.\n\nSo here's our basic Vue template:\n\n<template>\n  <div contenteditable @input=\"handleInput\" @keydown=\"handleKeydown\" />\n</template>\n\n\nIt's beautifully simple. (If you need to support IE, you can listen to keyup instead.) Note that we haven't bound the innerHTML to value because that would have the effect of resetting the cursor position on keystroke.\n\nWe're going to use execCommand to control the formatting of the HTML within the contenteditable element. Bear in mind that execCommand is deprecated [https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand] and may behave inconsistently across browsers — but for simple things like we need here, it's fine.\n\nNow we need to implement a handler for input events.\n\n<script lang=\"ts\">\nconst exec = (command: string, value?: string) =>\n  document.execCommand(command, false, value)\n\nconst queryCommandValue = (command: string) =>\n  document.queryCommandValue(command)\n\nexport default {\n  props: {\n    value: { type: String, default: '' },\n  },\n  mounted() {\n    this.$el.innerHTML = this.value\n  },\n  // We need to ensure we update the innerHTML when it changes,\n  // without resetting the cursor.\n  watch: {\n    value(newValue) {\n      if (this.$el.innerHTML !== newValue) this.$el.innerHTML = newValue\n    },\n  },\n  methods: {\n    // We emit changes as HTML. Alternatively you could serialise\n    // the innerHTML, which might require debouncing the input\n    // for performance reasons.\n    handleInput(e: InputEvent | KeyboardEvent) {\n      const { firstChild } = e.target as HTMLElement\n\n      if (firstChild && firstChild.nodeType === 3) exec('formatBlock', '<p>')\n      else if (this.$el.innerHTML === '<br>') this.$el.innerHTML = ''\n\n      this.$emit('input', this.$el.innerHTML)\n    },\n\n    // You could use a handler like this to listen to\n    // the `keyup` event in IE.\n    handleDelayedInput(e: KeyboardEvent) {\n      this.$nextTick(() => this.handleInput(e))\n    },\n  },\n}\n</script>\n\n\nNow we have a basic working component that will serve as a foundation for extension. For example:\n\n// Here we can handle keyboard shortcuts.\nhandleKeydown(e: KeyboardEvent) {\n  if (\n    e.key.toLowerCase() === 'enter' &&\n    queryCommandValue('formatBlock') === 'blockquote'\n  ) {\n    this.$nextTick(() => exec('formatBlock', '<p>'))\n  } else if (e.ctrlKey) {\n    switch (e.key.toLowerCase()) {\n      case 'b':\n        e.preventDefault()\n        this.$nextTick(() => exec('bold'))\n        break\n\n      case 'i':\n        e.preventDefault()\n        this.$nextTick(() => exec('italic'))\n        break\n\n      case 'u':\n        e.preventDefault()\n        this.$nextTick(() => exec('underline'))\n        break\n\n      default:\n        break\n    }\n  }\n},\n\n\nThis is a pretty basic example. Obviously, it's possible to do a whole lot more, including listening for patterns of keystrokes. And — caveat emptor — for anything too much more complicated, it would probably be worth using a rich text component like tiptap that doesn't rely on contenteditable or document.execCommand.",
  "title": "Building your own Vue rich text component",
  "updatedAt": "2026-05-18T10:18:04.927Z"
}