@validol4ik/vue-editor
v0.1.3
Published
Lightweight WYSIWYG editor for Vue 3 built on Tiptap
Readme
Vue Editor
A lightweight, good-looking rich text editor for Vue 3. Built on Tiptap and ProseMirror. Free and MIT licensed.
- 🧱 Solid engine. It runs on ProseMirror, the same engine behind the editors of the New York Times, Atlassian and GitLab. Undo/redo, selection and pasting just work.
- 🪶 Lightweight
- 🎨 Easy to theme. CSS variables for colors, each with a dark mode variant. It fits your design instead of forcing its own.
- 🌗 Dark mode with one prop.
- 🔁 HTML or JSON. Store whichever suits you and convert between them at any time.
- 📋 Clean paste. Content from Word, Google Docs and web pages keeps its formatting; junk markup is dropped.
- 🛡️ Safe output. Only known elements are produced;
<script>tags andjavascript:links never make it through. - Fully typed with TypeScript.
- 🆓 MIT licensed, along with every Tiptap package it uses.
Installation
npm
npm install @validol4ik/vue-editoryarn
yarn add @validol4ik/vue-editorRequires Vue 3.3 or newer in your project.
Quick start
<script setup lang="ts">
import { ref } from "vue";
import { VueEditor } from "@validol4ik/vue-editor";
import "@validol4ik/vue-editor/style.css";
const content = ref("<p>Hello, <strong>world</strong>!</p>");
</script>
<template>
<VueEditor v-model="content" placeholder="Start typing…" />
</template>Examples
Store JSON instead of HTML
JSON is a structured tree, which makes it easy to search, transform or migrate later.
<script setup lang="ts">
import { ref } from "vue";
import { VueEditor, type JSONContent } from "@validol4ik/vue-editor";
const doc = ref<JSONContent | null>(null);
</script>
<template>
<VueEditor v-model="doc" output="json" />
</template>Dark mode
<VueEditor v-model="content" :dark="isDark" />Pick your own toolbar
List the buttons and groups you want, in order. A group name renders all of its buttons together, and groups get separators around them automatically. "|" adds a separator anywhere else.
<VueEditor
v-model="comment"
:toolbar="['history', 'bold', 'italic', 'underline', '|', 'lists', 'link']"
/>Pass :toolbar="[]" to hide the toolbar and keep keyboard shortcuts only.
Add your own toolbar buttons
Register a button, then list its name in toolbar. Buttons registered with registerToolbarButton are available to every editor in the app; buttons registered on a controller only to that editor. Both return a function that removes the button.
import { registerToolbarButton } from "@validol4ik/vue-editor";
registerToolbarButton({
name: "clearAll",
title: "Clear all",
icon: "clear",
action: (editor) => editor.chain().focus().clearContent().run(),
});<script setup lang="ts">
const editor = useVueEditor();
editor.registerToolbarButton({
name: "variables",
title: "Insert variable",
label: "Variables",
type: "dropdown",
items: [
{ label: "First name", action: (e) => e.chain().focus().insertContent("{first_name}").run() },
{ label: "Score", action: (e) => e.chain().focus().insertContent("{score}").run() },
],
});
</script>
<template>
<VueEditor v-model="content" :editor="editor" :toolbar="['history', 'format', 'clearAll', 'variables']" />
</template>Button types
| type | Fields | Renders |
|---|---|---|
| "button" (default) | action(editor), isActive?(editor), isDisabled?(editor) | A button that runs action |
| "dropdown" | items: { label, icon?, action, isActive?, isDisabled? }[], layout?, showActiveIcon?, showArrow?, isActive?, isDisabled? | A button that opens a menu of items |
| "component" | component, options? | Your component, which receives editor, dark and options props |
Every type also takes name, group, title, icon and label. editor is the Tiptap instance.
Dropdowns. layout: "list" (default) opens a vertical menu with each item's icon and label. layout: "row" opens a row of icon buttons, with each label shown as a tooltip. showActiveIcon: true makes the dropdown button show the icon of the active item, for example the current alignment. Dropdown buttons show an arrow; showArrow: false hides it.
registerToolbarButton({
name: "formatDropdown",
title: "Format",
icon: "bold",
type: "dropdown",
layout: "row",
items: [
{ label: "Bold", icon: "bold", action: (editor) => editor.chain().focus().toggleBold().run() },
{ label: "Italic", icon: "italic", action: (editor) => editor.chain().focus().toggleItalic().run() },
{ label: "Underline", icon: "underline", action: (editor) => editor.chain().focus().toggleUnderline().run() },
],
});Groups. Buttons with the same group render together where the group name is listed in toolbar, in registration order. A button can join a built-in group: group: "format" adds it after Strikethrough. A button and a group should not share a name; when they do, the button is shown and an error is logged.
Icons. icon accepts a built-in icon name ("bold", "link", …), an SVG path for a 24×24 stroke icon ("M5 12h14"), <svg> markup, an image URL, or a Vue component. Without an icon, the button shows its label or title as text.
Replacing built-in buttons. Registering a button with a built-in name, such as bold, replaces it and keeps its place in the group. Controller buttons take precedence over global ones, which take precedence over modules and built-ins.
ToolbarButton, ToolbarIcon and ToolbarPopover are exported, so a custom component can match the built-in buttons.
Fully custom toolbar
The toolbar slot replaces the whole toolbar with your own markup. It receives the Tiptap editor and dark. Inside it, VueEditorToolbarItem renders any built-in or registered button, or a whole group, by name.
<script setup lang="ts">
import { VueEditor, VueEditorToolbarItem } from "@validol4ik/vue-editor";
</script>
<template>
<VueEditor v-model="content">
<template #toolbar="{ editor }">
<div class="my-toolbar">
<VueEditorToolbarItem name="format" />
<VueEditorToolbarItem name="link" />
<button class="my-button" @click="editor.chain().focus().clearContent().run()">
Clear
</button>
</div>
</template>
</VueEditor>
</template>The slot is hidden in readonly mode, like the default toolbar. Add the vue-editor__toolbar class to your container to keep the default toolbar look.
Color picker palettes
To customize the palettes of the text color and highlight pickers, use the colors and highlight-colors props:
<VueEditor
v-model="content"
:colors="['#111827', '#dc2626', '#16a34a', '#2563eb']"
:highlight-colors="['#fef08a', '#bbf7d0']"
/>The palettes affect only the content colors available to writers. To style the editor itself, see Theming.
Read-only view
Shows the content with the editor's styling, without the toolbar.
<VueEditor :model-value="article" readonly />Displaying content
Headings, lists, paragraphs and links rely on the default styles of the environment and may vary slightly between browsers and applications.
For rendering identical to the editor regardless of the environment, import content.css and apply the vue-editor-content class to the container:
<script setup lang="ts">
import "@validol4ik/vue-editor/content.css";
</script>
<template>
<article class="vue-editor-content" v-html="article" />
</template>Convert between HTML and JSON
import { htmlToJson, jsonToHtml } from "@validol4ik/vue-editor";
const json = htmlToJson("<p><strong>Hi</strong> there</p>");
const html = jsonToHtml(json);Got HTML from a source you don't trust? Pass it through both helpers to keep only what the editor supports:
const safeHtml = jsonToHtml(htmlToJson(untrustedHtml));Form validation
The editor provides the values to validate; the rules stay in your application or validation library. Use error to highlight the editor and error-message to show the message under it.
<script setup lang="ts">
import { computed, ref } from "vue";
import { VueEditor, useVueEditor } from "@validol4ik/vue-editor";
const content = ref("");
const editor = useVueEditor();
const error = computed(() => {
if (!editor.getText().trim()) return "Description is required";
if (editor.characterCount < 30) return "At least 30 characters";
return "";
});
</script>
<template>
<VueEditor
v-model="content"
:editor="editor"
:error="!!error"
:error-message="error"
/>
</template>Common rules:
| Rule | Check |
|---|---|
| Required | editor.getText().trim() !== "" |
| Minimum length | editor.characterCount >= min |
| Maximum length | editor.characterCount <= max, or the max-length prop for a hard limit |
| Word count | editor.wordCount <= max |
Validate the text through the controller rather than the v-model value: HTML includes tags, so its length differs from the visible text. In HTML mode an empty editor emits "", while content with only spaces is not empty, which is why getText().trim() is the reliable required check.
Autosave
Use debounce to delay v-model updates until the writer pauses, for example to save while typing:
<VueEditor v-model="content" :debounce="800" @update:model-value="save" />A pending update is sent immediately when the editor loses focus or unmounts, so no changes are lost. Character and word counts stay up to date while typing.
Character limit
<VueEditor v-model="bio" :max-length="500" />Typing stops at the limit, and pasted content is trimmed to fit. The limit can be changed at any time.
Control the editor from code
useVueEditor returns an object for controlling the editor and reading its state. Connect it with the editor prop:
<script setup lang="ts">
import { ref } from "vue";
import { VueEditor, useVueEditor } from "@validol4ik/vue-editor";
const content = ref("");
const editor = useVueEditor();
editor.onFocus(() => console.log("Editor focused"));
</script>
<template>
<VueEditor v-model="content" :editor="editor" :max-length="500" />
<button @click="editor.insertContent('Hello! ')">Insert greeting</button>
<button @click="editor.clear()">Clear</button>
<span>{{ editor.characterCount }} / 500</span>
</template>State is reactive and reads the same way in the script and the template. Methods can be called at any time; calls made before the editor is mounted are ignored. Event subscriptions are removed automatically when the calling component unmounts, and each subscription also returns a function to remove it earlier.
The underlying Tiptap instance is available as editor.tiptap for anything beyond the built-in methods:
editor.tiptap?.chain().focus().toggleBold().run();Modules
Optional features ship as separate modules, so their code and dependencies are included only when used. Import a module and pass it to the modules prop.
Color picker
Replaces the palettes of the text color and highlight pickers with the vue-color Sketch picker. The colors and highlight-colors palettes are shown as its preset colors, and the selected color is applied while picking.
<script setup lang="ts">
import { VueEditor } from "@validol4ik/vue-editor";
import { ColorPicker } from "@validol4ik/vue-editor/color-picker";
</script>
<template>
<VueEditor v-model="content" :modules="[ColorPicker]" />
</template>Use configure to change the options. It returns a new module and leaves the original unchanged.
const modules = [ColorPicker.configure({ alpha: true })];| Option | Type | Default | Description |
|---|---|---|---|
| alpha | boolean | false | Enables transparency. Colors are stored as rgba() instead of hex. |
| fields | boolean | true | Shows the color value input fields. |
The picker follows the editor's dark prop.
Font weight
Adds a font weight select next to the font size control. Without it, the Bold button covers text weight.
<script setup lang="ts">
import { VueEditor } from "@validol4ik/vue-editor";
import { FontWeight } from "@validol4ik/vue-editor/font-weight";
</script>
<template>
<VueEditor v-model="content" :modules="[FontWeight]" />
</template>Choosing a weight removes bold from the selection, and turning bold on removes the chosen weight. The available weights can be changed with configure:
const modules = [
FontWeight.configure({
weights: [
{ value: "400", label: "Regular" },
{ value: "600", label: "Semibold" },
],
}),
];| Option | Type | Default | Description |
|---|---|---|---|
| weights | { value: string; label: string }[] | Light to Extra bold (300–800) | Options of the select. |
Weights render only when the font in use provides them.
Server-side rendering (Nuxt, Quasar SSR)
Render the editor on the client only:
<ClientOnly>
<VueEditor v-model="content" />
</ClientOnly>htmlToJson and jsonToHtml need a DOM. On the server, use a DOM implementation such as happy-dom, or Tiptap's @tiptap/html/server.
Theming
Colors come from CSS variables. Every color has a light variable and a dark variable used when the dark prop is on. Set them wherever you like: on :root, on a wrapper, or on .vue-editor.
:root {
--vue-editor-accent: #6741d9;
--vue-editor-dark-accent: #b197fc;
--vue-editor-dark-bg: #18181b;
--vue-editor-radius: 12px;
}| Variable | Dark mode variable | Light default | Dark default | Used for |
|---|---|---|---|---|
| --vue-editor-bg | --vue-editor-dark-bg | #ffffff | #1e1f22 | Editor and popup background |
| --vue-editor-text | --vue-editor-dark-text | #1f2328 | #e6e8eb | Text and icons |
| --vue-editor-muted | --vue-editor-dark-muted | #8b939c | #7d848c | Placeholder |
| --vue-editor-border | --vue-editor-dark-border | #d8dde3 | #3a3d42 | Borders and separators |
| --vue-editor-toolbar-bg | --vue-editor-dark-toolbar-bg | #f6f8fa | #26282c | Toolbar background |
| --vue-editor-hover | --vue-editor-dark-hover | #e9edf1 | #34373c | Button hover |
| --vue-editor-accent | --vue-editor-dark-accent | #2f6feb | #6ea8fe | Active buttons, focus ring, links |
| --vue-editor-error | --vue-editor-dark-error | #e03131 | #ff8787 | Error border and message |
| Variable | Default | Used for |
|---|---|---|
| --vue-editor-radius | 8px | Corner radius |
| --vue-editor-min-height | 160px | Minimum height of the writing area |
| --vue-editor-max-height | none | Maximum height of the writing area; longer content scrolls below the toolbar |
The editor inherits font-family and font-size from its parent, so it matches your app's typography automatically.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| modelValue | string \| JSONContent \| null | null | The content. Accepts HTML or JSON regardless of output. |
| output | "html" \| "json" | "html" | Format emitted through v-model. |
| placeholder | string | "" | Hint shown while the editor is empty. |
| dark | boolean | false | Uses the dark color set. |
| readonly | boolean | false | Disables editing and hides the toolbar. |
| autofocus | boolean \| "start" \| "end" | false | Focuses the editor on mount. true places the cursor at the end. |
| maxLength | number | – | Maximum number of characters. |
| debounce | number | – | Delays v-model updates by the given number of milliseconds. |
| error | boolean | false | Shows the error state. |
| errorMessage | string | – | Message shown under the editor in the error state. |
| editor | VueEditorController | – | Controller created by useVueEditor. |
| modules | VueEditorModule[] | [] | Optional modules. |
| toolbar | string[] | all groups | Buttons and groups to show, in order. See Pick your own toolbar. |
| colors | string[] | 10 colors | Swatches in the text color picker. |
| highlightColors | string[] | 5 colors | Swatches in the highlight picker. |
Events
Every event is available both on the component and on the useVueEditor controller.
| Event | Component | Controller | Payload |
|---|---|---|---|
| Ready | @ready | editor.onReady(fn) | – |
| Content changed | @update:model-value | editor.onChange(fn) | string \| JSONContent (respects debounce) |
| Focus | @focus | editor.onFocus(fn) | – |
| Blur | @blur | editor.onBlur(fn) | – |
| Selection changed | @selection-change | editor.onSelectionChange(fn) | – |
onReady called after the editor is already mounted runs the listener immediately.
Slots
| Slot | Props | Description |
|---|---|---|
| toolbar | { editor: Editor; dark: boolean } | Replaces the whole toolbar. See Fully custom toolbar. |
useVueEditor
const editor = useVueEditor();State
| Property | Type | Description |
|---|---|---|
| isReady | boolean | The editor is mounted |
| isEmpty | boolean | The document has no content |
| isFocused | boolean | The editor has focus |
| characterCount | number | Number of characters |
| wordCount | number | Number of words |
| canUndo | boolean | There is a change to undo |
| canRedo | boolean | There is a change to redo |
| tiptap | Editor \| null | The underlying Tiptap instance |
Methods
| Method | Description |
|---|---|
| focus(position?) | Focuses the editor. position: "start", "end" (default) or "all". |
| blur() | Removes focus. |
| clear() | Removes all content. |
| setContent(content) | Replaces the content. Accepts HTML or JSON. |
| insertContent(content) | Inserts text, HTML or JSON at the cursor. |
| getHTML() | Returns the content as HTML. An empty document returns "". |
| getJSON() | Returns the content as JSON. |
| getText() | Returns the content as plain text. |
| undo() / redo() | Undoes or redoes the last change. |
| selectAll() | Selects the whole document. |
| registerToolbarButton(button) | Adds a toolbar button to this editor only. Returns a function that removes it. |
Toolbar items
The default toolbar is ["history", "text", "format", "colors", "align", "lists", "insert"].
| Group | Buttons |
|---|---|
| history | undo · redo |
| text | heading · fontSize · fontWeight (font weight module) |
| format | bold · italic · underline · strike |
| colors | color · highlight · clear |
| align | alignDropdown · lineHeight |
| lists | listDropdown |
| insert | link |
alignDropdown and listDropdown open a row with the alignment or list buttons and show the current choice. The separate buttons are available by name, outside the groups: alignLeft · alignCenter · alignRight · alignJustify · bulletList · orderedList.
<VueEditor :toolbar="['history', 'format', 'alignLeft', 'alignCenter', 'alignRight', 'lists']" />"|" adds a separator.
Keyboard shortcuts
| Action | macOS | Windows / Linux | |---|---|---| | Bold | ⌘ B | Ctrl B | | Italic | ⌘ I | Ctrl I | | Underline | ⌘ U | Ctrl U | | Strikethrough | ⌘ ⇧ S | Ctrl Shift S | | Undo | ⌘ Z | Ctrl Z | | Redo | ⌘ ⇧ Z | Ctrl Shift Z | | Heading 1–3 | ⌘ ⌥ 1–3 | Ctrl Alt 1–3 | | Bullet list | ⌘ ⇧ 8 | Ctrl Shift 8 | | Numbered list | ⌘ ⇧ 7 | Ctrl Shift 7 | | Highlight | ⌘ ⇧ H | Ctrl Shift H | | Align left / center / right / justify | ⌘ ⇧ L / E / R / J | Ctrl Shift L / E / R / J | | Line break | ⇧ Enter | Shift Enter | | Tab character (indents a list item inside a list) | Tab | Tab |
Markdown-style shortcuts work while typing too: # for a heading, - for a bullet list, 1. for a numbered list, **bold**, *italic*.
Contributing
yarn install
yarn storybook # interactive playground at http://localhost:6007
yarn test # unit tests
yarn build # type check, tests and library buildCredits
- Tiptap and ProseMirror power the editing.
- Toolbar icons are adapted from Lucide (ISC).
