@naresh-io/rich-text-editor-vue
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 (
imageRefmark). 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 imageslash entry, aninlineImagenode), 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
imagenode is a 100×100 tile drawn from itsthumbnailSrcattribute (usually adata:URI; a 200 px square is sharp on 2× screens). EachinlineImageuses its ownthumbnailSrc. - Read-only, each tile is a button (
role="button", focusable, named "View full size: ") that emitsfigure-openon click, Enter or Space. Showing the figure is the host's job: the payload (FigureOpenPayload) has the original assrc, the caption aslabel, and thefigureelement for anything else the host reads from its markup. It extendsInlineImageOpenPayload, so it can go straight to the built-inImageLightbox. A figure with no usablesrcisn't a button. The<img>also keeps the original indata-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. thumbnailSrcis 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 returnthumbnails: { 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
numberis 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
startcounts 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;
comparisontints each compared column,key-valuesputs values right-aligned in mono,mnemonicmakes the first column a tile,indexColumnnarrows 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
recalltable 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 emitschecklist-toggle({ itemId, checked }), the host saves it and passes the new state back.savingmakes the box pulse,failedshakes 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
.darkor[data-theme='dark']. Override any--rr-*token on.rte-readerto 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
A rich text editor Vue 3 component with WYSIWYG, Markdown, and split-preview mod...
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 | |
| 0.8.2 | ^3.5.0 | MIT | |
| 0.8.1 | ^3.5.0 | MIT | |
| 0.8.0 | ^3.5.0 | MIT | |
| 0.7.1 | ^3.5.0 | MIT | |
| 0.7.0 | ^3.5.0 | MIT | |
| 0.6.0 | ^3.5.0 | MIT | |
| 0.5.1 | ^3.5.0 | MIT | |
| 0.4.2 | ^3.5.0 | MIT | |
| 0.4.1 | ^3.5.0 | MIT | |
| 0.4.0 | ^3.5.0 | MIT | |
| 0.3.0 | ^3.5.0 | MIT | |
| 0.2.0 | ^3.5.0 | MIT | |
| 0.1.2 | ^3.5.0 | MIT | |
| 0.1.1 | ^3.5.0 | MIT |