@naresh-io/rich-text-editor-vue

A feature-rich WYSIWYG editor built with TipTap and Vue 3
3,383
Install
npm install @naresh-io/rich-text-editor-vue
Latest Version:0.11.0
Vue:3
License:MIT
Last Updated:Oct 7, 2026
Links: npm
Maintainer: naresh-io

Rich Text Editor Vue

A feature-rich WYSIWYG editor for Vue 3, built on top of TipTap.

Features

  • 📝 Full-featured editing: Text formatting, headings, lists, tables, images, code blocks, and more
  • 🖼️ Image support: Upload, resize, align, and add captions to images
  • 📊 Advanced tables: Merge cells, adjust alignment, add/remove rows and columns
  • 📐 Column layouts: Create multi-column content with 5 preset layouts
  • 🎯 Collapsible sections: Details/summary blocks for organized content
  • 📑 Table of Contents: Auto-generated TOC with smooth scrolling
  • ⌨️ Slash commands: Notion-style / menu for quick insertions
  • 😊 Emoji support: Built-in emoji picker
  • 🎨 Customizable: Headless mode for full styling control
  • ♿ Accessible: Keyboard shortcuts and screen reader support
  • 📱 Responsive: Mobile-friendly interface

Callouts and formulas (0.8.0)

Type /callout for a boxed note with a tone (warning, tip, danger, info) and a title line, and /formula or /inline formula for LaTeX rendered by KaTeX; clicking a formula opens the popover again. Import KaTeX's stylesheet once in your app:

import 'katex/dist/katex.min.css';

Override the --rte-callout-<tone>-{border,bg,text,icon} tokens to restyle the four tones.

Image sources (0.9.0)

Images carry an optional attribution in source and sourceUrl, shown as its own "Source:

Installation

npm install @naresh-io/rich-text-editor-vue

Basic Usage

<script setup lang="ts">
import { ref } from 'vue';
import { RichTextEditor } from '@naresh-io/rich-text-editor-vue';
import '@naresh-io/rich-text-editor-vue/rich-text-editor.css';

const content = ref({
  type: 'doc',
  content: [
    {
      type: 'paragraph',
      content: [{ type: 'text', text: 'Start writing...' }],
    },
  ],
});

async function handleImageUpload(file, onProgress) {
  // Upload to your server
  const formData = new FormData();
  formData.append('image', file);

  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData,
  });

  const data = await response.json();

  return {
    url: data.url,
    assetId: data.id,
    width: data.width,
    height: data.height,
  };
}
</script>

<template>
  <RichTextEditor
    v-model="content"
    :image-upload-handler="handleImageUpload"
    placeholder="Start writing..."
  />
</template>

Props

Prop Type Default Description
v-model TiptapDocument required Editor content (JSON)
editable boolean true Enable/disable editing
autofocus boolean false Auto-focus on mount
placeholder string 'Start writing...' Placeholder text
characterLimit number undefined Max character count (unset = unlimited)
tableOfContents boolean false Show table of contents sidebar
showFloatingMenus boolean false Show bubble menu, image/column toolbars, table handle, drag handle
imageUploadHandler ImageUploadHandler undefined Handle image uploads
headless boolean false Disable default styles
glossary GlossaryHandler undefined View-time glossary term highlighting (see below)
reader ReaderProp undefined Reader treatment for study notes (see Reader Mode)
thumbnails boolean false Draw images from their thumbnails (see Thumbnail Mode)

Inline Images

Whenever an imageUploadHandler is supplied and the editor is editable, authors can:

  • Attach an image to selected words (bubble-menu button) — the words become a tappable trigger (imageRef mark). Readers tap it (or press Enter/Space on it) to open the image in the built-in lightbox.
  • Insert an emoji-sized thumbnail in the text flow (/inline image slash entry, an inlineImage node), tappable to expand.

Reading such content never required a gate, and creating it no longer has one. Both components emit image-open on activation.

Thumbnail Mode

thumbnails (on RichTextEditor and RichTextPreview, read once at mount) draws every image from a small thumbnail the host puts in the content, and never downloads the original just to fill the page:

<RichTextEditor v-model="note" :editable="false" thumbnails @figure-open="openLightbox" />
  • Each image node is a 100×100 tile drawn from its thumbnailSrc attribute (usually a data: URI; a 200 px square is sharp on 2× screens). Each inlineImage uses its own thumbnailSrc.
  • Read-only, each tile is a button (role="button", focusable, named "View full size: ") that emits figure-open on click, Enter or Space. Showing the figure is the host's job: the payload (FigureOpenPayload) has the original as src, the caption as label, and the figure element for anything else the host reads from its markup. It extends InlineImageOpenPayload, so it can go straight to the built-in ImageLightbox. A figure with no usable src isn't a button. The <img> also keeps the original in data-full-src. Inline images open the built-in lightbox with the original, as before.
  • An image without a thumbnail shows a placeholder glyph (.image-figure--no-thumbnail, .rte-inline-image--placeholder) and still opens its original on click.
  • thumbnailSrc is display-only: it is never parsed from or rendered into HTML, and hosts should strip it before storing content.
  • Tiles sit on a white backing, so a transparent diagram stays readable on a dark page (hosts should also flatten tile thumbnails onto white; it keeps them small).
  • Editable: each figure sits on its own row, tile on the left and its caption (still editable) beside it. Resize handles and the toolbar's alignment and size buttons are hidden; alt text, source and delete remain, plus View full size (also a double-click on the tile, or the openImage() command), which opens the original in the built-in lightbox. An upload handler can return thumbnails: { tile, inline } so a new image shows as a tile at once.

Glossary Highlighting

Pass a term index and matches are decorated at view time — nothing is written into the document, so new terms retroactively highlight existing content:

<RichTextPreview
  v-model="content"
  :glossary="{
    terms: [{ id: '01H…', term: 'ECG', aliases: ['EKG'], type: 'abbreviation' }],
    getTermContent: async (id) => await api.glossary.show(id), // { doc, imageUrl?, imageAlt? }
    ui: 'popover', // default; 'none' = only emit term-activate, bring your own UI
  }"
  @term-activate="onTermActivate"
/>

Highlighting skips headings, code, links and imageRef spans. In editable mode it is off unless glossary.editable is true. glossary.occurrences: 'first-per-block' reduces highlight density in long documents.

Read-Only Mode

Use RichTextPreview for displaying content without editing:

<script setup lang="ts">
import { RichTextPreview } from '@naresh-io/rich-text-editor-vue';
import '@naresh-io/rich-text-editor-vue/rich-text-editor.css';
</script>

<template>
  <RichTextPreview v-model="content" />
</template>

RichTextPreview also accepts glossary and a minimal prop (embedded mode: no ⌘F interceptor, no search panel, no TOC — used internally by the glossary popover).

Learning blocks

Study blocks are real nodes, each part its own node, so any renderer (web, mobile) maps node types to views and nothing is read from the wording. Insert them from the slash menu ("Learning"):

Block Node Parts
Key takeaways keyTakeaways (title) a bullet list
Pathway pathway (title) one paragraph per step
Mnemonic mnemonic (word, title) mnemonicItem = mnemonicKey + mnemonicText
Reveal list revealList (title) revealItem = revealPrompt + revealAnswer
Clinical case clinicalCase (title) caseStem, optional caseQuestion, optional caseAnswer
MCQ analysis mcqAnalysis mcqStem, mcqOption (correct) = mcqChoice + optional mcqWhy
Algorithm algorithm (title) paragraphs, algorithmQuestion, algorithmBranch (label) + optional algorithmOutcome
Figure to come figureSlot (label, description) —

Stored presentation attributes: heading number (kept for the data; no reader shows it), table variant (comparison, key-values, recall, mnemonic) and indexColumn (row or column menu → "Table style"), callout tone update.

Reader Mode

reader (on RichTextEditor and RichTextPreview) turns on a reading treatment for study notes. It renders what is stored — nothing is inferred from the text, nothing is written to the document. Import its stylesheet after the main one:

import '@naresh-io/rich-text-editor-vue/rich-text-editor.css';
import '@naresh-io/rich-text-editor-vue/styles/reader.css';
<RichTextEditor v-model="note" :editable="false" reader />
  • Each heading level looks different: h1 a large display title, h2 on a soft lavender band, h3 with a soft bar beside it, h4 in the primary ink, h5 a small-caps label, h6 a muted italic; h3, h5 and h6 line up with the band's text, h4 with the body. Bold text is semibold. A stored section number is never shown; numbers authors typed stay in the heading text.
  • List numbers and bullets are drawn in a first column that starts flush with the body text, with wrapped lines hanging under the text; a list stored with a later start counts from there. Checklists keep their own boxes.
  • Callouts: in read mode a white card with a border in the tone's colour, the title on a subtle tinted strip at the top and the content below it.
  • Tables: one slate-blue header tint and stripes; comparison tints each compared column, key-values puts values right-aligned in mono, mnemonic makes the first column a tile, indexColumn narrows a row-number first column.
  • ↑ and ↓ inside tables sit on a plum chip (direction only, never good-or-bad); in running text they are plain bold arrows, as is →. The only text treatment.
  • Callouts read as five tones: warning = exam trap, danger = never miss, tip = pearl, info = note, update.
  • Read-only, answers start closed: reveal-list rows and recall table cells behind "Reveal" (a row click opens it too) with "Show all" / "Hide all"; a case's answer behind "Show answer" (top right of the card); MCQ options are picked by their letter, then every reason shows, the correct option is marked and "Try again" starts over. Keyboard focus follows what opened.
  • Checklists are task items. Pass reader.checklist ({ checked, saving?, failed? }, item ids = the items' first paragraph ids) on a read-only mount and they become real checkboxes: a press emits checklist-toggle ({ itemId, checked }), the host saves it and passes the new state back. saving makes the box pulse, failed shakes it once; a click anywhere on a row ticks it; the list shows "3 of 10 done". findChecklistItemIds(doc) lists a stored document's item ids.
  • Figures run edge to edge on a neutral mat and are numbered in order; quote blocks read as note cards; code blocks keep their lines.
  • Editable editors get the same look with everything visible and nothing interactive.
  • Dark mode follows an ancestor .dark or [data-theme='dark']. Override any --rr-* token on .rte-reader to retheme.

Keyboard Shortcuts

Shortcut Action
Ctrl/Cmd + B Bold
Ctrl/Cmd + I Italic
Ctrl/Cmd + U Underline
Ctrl/Cmd + K Add link
Ctrl/Cmd + \ Clear formatting
Ctrl/Cmd + Z Undo
Ctrl/Cmd + Shift + Z Redo
/ Open slash command menu
: Open emoji picker

Headless Mode

For full styling control, use headless mode and provide your own CSS:

<RichTextEditor v-model="content" headless />

Then style using CSS variables:

:root {
  --rte-color-primary: #6366f1;
  --rte-color-primary-hover: #4f46e5;
  --rte-border-radius: 0.5rem;
  /* ... more variables */
}

TypeScript Support

Full TypeScript support with exported types:

import type {
  TiptapDocument,
  RichTextEditorProps,
  ImageUploadHandler,
  UploadResult,
} from '@naresh-io/rich-text-editor-vue';

License

MIT © Naresh IO

Contributing

Contributions are welcome! Please open an issue or PR on GitHub.

Related Packages

@looohz/rich-editor

A rich text editor Vue 3 component with WYSIWYG, Markdown, and split-preview mod...

388
tiptap-vuetify

Vuetify WYSIWYG editor based on Tiptap.

175,989 813
@markweave/vue2

Vue 2 adapter for the Markweave Markdown-first WYSIWYG editor.

9,500 1
@markweave/vue3

Vue 3 adapter for the Markweave Markdown-first WYSIWYG editor.

9,230 1

Version History

Version Released Vue License
0.11.0 ^3.5.0 MIT
0.10.0 ^3.5.0 MIT
0.9.0 ^3.5.0 MIT
0.8.4 ^3.5.0 MIT
0.8.3 ^3.5.0 MIT