A lightweight WYSIWYG text editor for Vue 3. AST-based, zero runtime dependencies, fully typed.
- AST document model -- content is a typed tree, not raw HTML. Schema rules enforce valid structure (e.g. lists cannot nest inside paragraphs).
- No
execCommand-- all formatting uses modern Range/Selection APIs via pure AST transforms. No deprecated browser APIs. - Zero runtime dependencies -- Vue 3 is the only peer dependency. 32.19 kB min / 9.32 kB gzip.
- v-model binding -- two-way HTML string binding. Set content programmatically, read it reactively.
- Plugin system -- every feature (bold, headings, lists) is a plugin. Add custom plugins, override built-ins, or use only what you need.
- Full TypeScript API -- typed commands (
toggleMark,setBlockType,toggleList,setLink,removeLink) and state inspection (isMarkActive,getBlockType,getHeadingLevel,getLinkHref). - Undo / Redo -- built-in history stack with Mod+Z / Mod+Shift+Z.
- SSR-safe -- no browser API access at module evaluation time.
- Themeable -- all visual properties exposed as CSS custom properties.
npm install eddy-editor
# or
pnpm add eddy-editorVue 3 is a peer dependency. @lucide/vue is also required for the built-in toolbar's icons; if you render a fully custom toolbar, you can skip it.
<template>
<eddy-editor v-model="content" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { EddyEditor } from 'eddy-editor'
import 'eddy-editor/style.css'
const content = ref('<p>Hello world</p>')
</script>The v-model value is an HTML string. On first render the editor is seeded with that string; every edit emits an updated HTML string back.
The default toolbar (bold, italic, underline, strikethrough, link, headings, lists) renders automatically. All built-in plugins are included unless you override them via the plugins prop.
The default toolbar renders automatically. Provide the #toolbar slot to replace it with your own.
The #toolbar slot exposes the EditorAPI directly:
<template>
<eddy-editor v-model="content">
<template #toolbar="{ editor }">
<button @mousedown.prevent="editor?.toggleMark('bold')">Bold</button>
<button @mousedown.prevent="editor?.toggleMark('italic')">Italic</button>
</template>
</eddy-editor>
</template>@mousedown.prevent is important -- it stops the click from blurring the editor before the command runs.
This works for simple cases, but the slot prop is not reactive to selection changes -- button active states won't update as the cursor moves.
To render no toolbar at all, pass an empty template:
<eddy-editor v-model="content">
<template #toolbar />
</eddy-editor>For a toolbar that reflects the current formatting at the cursor, create a component that receives the slot props and uses the useEditorState composable:
<!-- MyToolbar.vue -->
<template>
<div class="my-toolbar">
<button
:class="{ active: states.get('bold') }"
:aria-pressed="states.get('bold') ?? false"
@mousedown.prevent="editor?.toggleMark('bold')"
>
Bold
</button>
<button
:class="{ active: states.get('italic') }"
:aria-pressed="states.get('italic') ?? false"
@mousedown.prevent="editor?.toggleMark('italic')"
>
Italic
</button>
</div>
</template>
<script setup lang="ts">
import { toRef } from 'vue'
import { useEditorState, type EditorAPI, type EddyPlugin } from 'eddy-editor'
const props = defineProps<{
editor: EditorAPI | null
plugins: EddyPlugin[]
disabled: boolean
}>()
// Reactive Map<string, boolean> — updates on every selectionchange and input event
const states = useEditorState(toRef(props, 'editor'), props.plugins)
</script>Pass the slot props through to your component:
<eddy-editor v-model="content">
<template #toolbar="{ editor, plugins, disabled }">
<my-toolbar :editor="editor" :plugins="plugins" :disabled="disabled" />
</template>
</eddy-editor>useEditorState returns a reactive Ref<Map<string, boolean>> keyed by plugin name. It listens to selectionchange and input events, so your toolbar buttons stay in sync as the user moves the cursor between formatted and plain text.
The plugins prop gives you the full merged plugin list (built-ins + any consumer plugins), so you can also iterate over plugins dynamically instead of hardcoding each button.
| Shortcut | Action |
|---|---|
| Mod+B | Bold |
| Mod+I | Italic |
| Mod+U | Underline |
| Mod+K | Add / edit / remove link |
| Mod+Z | Undo |
| Mod+Shift+Z | Redo |
| Enter | New paragraph (exits headings into <p>) |
| Shift+Enter | Line break (<br>) |
"Mod" means Cmd on macOS, Ctrl on Windows/Linux.
Every feature in Eddy is a plugin. The full set of built-ins is loaded by default, but you can extend the editor with your own plugins or override any built-in by name.
import { createPlugin } from 'eddy-editor'
const codePlugin = createPlugin({
name: 'code',
keybinding: 'mod+e',
toolbar: {
label: '<>',
title: 'Inline code (Mod+E)',
},
command(api) {
api.toggleMark('bold') // use any EditorAPI method
},
isActive(api) {
return api.isMarkActive('bold')
},
})Pass plugins via the plugins prop. Any plugin whose name matches a built-in replaces it; new names are appended. The default toolbar picks them up automatically.
<eddy-editor v-model="content" :plugins="[codePlugin]" />All built-in plugins are exported individually. Build a custom plugin list to control exactly which features are available:
import { bold, italic, heading1, heading2, unorderedList } from 'eddy-editor'
const plugins = [bold, italic, heading1, heading2, unorderedList]<eddy-editor v-model="content" :plugins="plugins" />| Plugin | Export name | Keybinding |
|---|---|---|
| Bold | bold |
Mod+B |
| Italic | italic |
Mod+I |
| Underline | underline |
Mod+U |
| Strikethrough | strikethrough |
|
| Link | link |
Mod+K |
| Heading 1--6 | heading1 ... heading6 |
|
| Bullet list | unorderedList |
|
| Numbered list | orderedList |
The link plugin uses window.prompt to collect the URL. When the cursor sits inside an existing link, the prompt is preloaded with the current href; an empty submission removes the link. Only http:, https:, mailto:, tel:, relative paths, and fragment URLs are accepted -- javascript: and other unsafe schemes are rejected on both input and parse.
The api object passed to plugin command and isActive callbacks:
| Method | Description |
|---|---|
toggleMark(mark) |
Toggle bold, italic, underline, or strikethrough |
setBlockType(type, attrs?) |
Set block to 'paragraph' or 'heading' with optional { level: 1-6 } |
toggleList(ordered) |
Toggle unordered (false) or ordered (true) list |
setLink(href) |
Apply a link to the selection (or update the link under a collapsed cursor) |
removeLink() |
Remove the link mark from the selection or the link range under a collapsed cursor |
insertParagraph() |
Insert a new paragraph (Enter key behaviour) |
insertHardBreak() |
Insert a <br> line break (Shift+Enter behaviour) |
| Method | Returns | Description |
|---|---|---|
isMarkActive(mark) |
boolean |
Whether the mark is active at the cursor or across the selection |
getBlockType() |
'paragraph' | 'heading' | 'list' | 'mixed' |
Block type at the cursor |
getHeadingLevel() |
1-6 | null |
Heading level, or null if not in a heading |
getListType() |
'ordered' | 'unordered' | null |
List type, or null if not in a list |
getLinkHref() |
string | null |
The href of the link at the cursor, or null if not in a link |
| Property | Type | Description |
|---|---|---|
el |
HTMLElement | null |
The underlying contenteditable element |
doc |
DocumentNode |
The current AST document tree |
selection |
ASTSelection | null |
The current cursor/selection as AST positions |
MarkType is 'bold' | 'italic' | 'underline' | 'strikethrough' | 'link'. The link mark carries an attrs: { href } object; other marks have no attributes.
Import the default stylesheet:
import 'eddy-editor/style.css'Override any aspect with CSS custom properties:
:root {
--eddy-border: 1px solid #e2e8f0;
--eddy-border-radius: 0.5rem;
--eddy-focus-ring-color: #6366f1;
--eddy-toolbar-bg: #ffffff;
--eddy-toolbar-btn-hover-bg: #f1f5f9;
--eddy-toolbar-btn-active-bg: #e2e8f0;
--eddy-toolbar-btn-active-color: inherit;
--eddy-min-height: 200px;
--eddy-padding: 0.75rem 1rem;
--eddy-font-family: inherit;
--eddy-font-size: inherit;
--eddy-line-height: 1.6;
}Or skip the default stylesheet entirely and style .eddy-wrapper, .eddy-toolbar, .eddy-toolbar-btn, and .eddy-editor yourself.
The document model types and HTML conversion utilities are exported for server-side processing:
import { parseHTML, serializeToHTML } from 'eddy-editor'
import type { DocumentNode } from 'eddy-editor'
const doc: DocumentNode = parseHTML('<p>Hello <strong>world</strong></p>')
const html: string = serializeToHTML(doc)The full set of AST node types (DocumentNode, BlockNode, InlineNode, TextNode, Mark, etc.) is exported as TypeScript types.
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue |
string |
-- | HTML content (use with v-model) |
plugins |
EddyPlugin[] |
[] |
Additional or replacement plugins |
disabled |
boolean |
false |
Disables editing and toolbar controls |
| Slot | Slot props | Description |
|---|---|---|
toolbar |
{ editor: EditorAPI | null, plugins: EddyPlugin[], disabled: boolean } |
Rendered above the editing area. Falls back to the built-in <eddy-toolbar> when not given. |
| Prop | Type | Description |
|---|---|---|
editor |
EditorAPI | null |
The editor API instance (from slot prop) |
plugins |
EddyPlugin[] |
Merged plugin list (from slot prop) |
disabled |
boolean |
Whether controls are disabled (from slot prop) |
interface EddyPlugin {
name: string
keybinding?: string
toolbar?: { label: string; title: string; icon?: Component }
command(api: EditorAPI): void
isActive?(api: EditorAPI): boolean
}Type-safe factory for authoring plugins. Returns the config unchanged; the value is in TypeScript inference.
Composable that returns a reactive Ref<Map<string, boolean>> of plugin active states. The api argument should be a Ref<EditorAPI | null> — use toRef(props, 'editor') to create one from a prop. Listens to selectionchange and input events so toolbar buttons stay in sync with the cursor position. Must be called inside a component's setup (requires lifecycle hooks).
MIT