JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 90
  • Score
    100M100P100Q90364F
  • License MIT

A lightweight Vue 3 WYSIWYG text editor

Package Exports

  • eddy-editor
  • eddy-editor/style.css

Readme

Eddy Editor

A lightweight WYSIWYG text editor for Vue 3. AST-based, zero runtime dependencies, fully typed.

Live demo

Features

  • 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. 28.40 kB min / 8.23 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) and state inspection (isMarkActive, getBlockType, getHeadingLevel).
  • 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.

Installation

npm install eddy-editor
# or
pnpm add eddy-editor

Vue 3 is a peer dependency. If you use the built-in <eddy-toolbar>, also install @lucide/vue for its icons. If you render a custom toolbar, it is not required.

Basic usage

<template>
  <eddy-editor v-model="content">
    <template #toolbar="{ editor, plugins, disabled }">
      <eddy-toolbar :editor="editor" :plugins="plugins" :disabled="disabled" />
    </template>
  </eddy-editor>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { EddyEditor, EddyToolbar } 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.

All built-in plugins (bold, italic, headings, lists, etc.) are included by default.

Custom toolbar

<eddy-toolbar /> is optional. You can build a fully custom toolbar in two ways.

Inline via scoped slot

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.

Custom toolbar component with reactive state

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.

Keyboard shortcuts

Shortcut Action
Mod+B Bold
Mod+I Italic
Mod+U Underline
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.

Plugin system

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.

Writing a plugin

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')
  },
})

Using custom plugins

Pass plugins via the plugins prop. Any plugin whose name matches a built-in replaces it; new names are appended.

<eddy-editor v-model="content" :plugins="[codePlugin]">
  <template #toolbar="{ editor, plugins, disabled }">
    <eddy-toolbar :editor="editor" :plugins="plugins" :disabled="disabled" />
  </template>
</eddy-editor>

Using built-in plugins individually

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">

Built-in plugins

Plugin Export name Keybinding
Bold bold Mod+B
Italic italic Mod+I
Underline underline Mod+U
Strikethrough strikethrough
Heading 1--6 heading1 ... heading6
Bullet list unorderedList
Numbered list orderedList

EditorAPI

The api object passed to plugin command and isActive callbacks:

Commands

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
insertParagraph() Insert a new paragraph (Enter key behaviour)
insertHardBreak() Insert a <br> line break (Shift+Enter behaviour)

State inspection

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

Properties

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'.

Styling

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.

AST utilities

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.

API reference

<eddy-editor>

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

<eddy-toolbar>

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)

EddyPlugin

interface EddyPlugin {
  name: string
  keybinding?: string
  toolbar?: { label: string; title: string; icon?: Component }
  command(api: EditorAPI): void
  isActive?(api: EditorAPI): boolean
}

createPlugin(config)

Type-safe factory for authoring plugins. Returns the config unchanged; the value is in TypeScript inference.

useEditorState(api, plugins)

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).

License

MIT