Features

Rich Text Editor

Complete guide to using the Nuxt Crouton editor layer for rich content editing
Status: Beta

The @friendlyinternet/nuxt-crouton-editor package provides a powerful, accessible rich text editing experience powered by TipTap, seamlessly integrated with Nuxt Crouton's form system. Perfect for blog posts, content management, and any text-heavy forms.

Overview

What's Included

The editor package is a modular addon layer that provides:

  • 🎨 WYSIWYG Editor - Beautiful, accessible rich text editing
  • ⚡ Auto-configured - TipTap extensions pre-configured and ready to use
  • 🧩 4 Ready-to-Use Components - Simple editor, preview, toolbar, and command palette
  • 🎯 Type-safe - Full TypeScript support
  • 🌙 Dark Mode - Automatic dark mode support with Nuxt UI theme integration
  • 📝 Form Integration - Works seamlessly with Nuxt Crouton's generated forms

When to Use

The editor addon is perfect for:

  • Blog posts and article content
  • Product descriptions
  • User comments and discussions
  • Documentation systems
  • Any text-rich content that needs formatting

Installation

Prerequisites

  • Nuxt 4+
  • @friendlyinternet/nuxt-crouton v1.0.1 or higher
  • @nuxt/icon v1.0.0 or higher

Install the Package

# Install the editor package
pnpm add @friendlyinternet/nuxt-crouton-editor

# Install required peer dependency
pnpm add @nuxt/icon

Configuration

Add the editor layer to your nuxt.config.ts:

export default defineNuxtConfig({
  extends: [
    '@friendlyinternet/nuxt-crouton',
    '@friendlyinternet/nuxt-crouton-editor'  // Add this layer
  ]
})

Quick Start

Basic Usage

The main editor component with a full-featured toolbar:

<script setup lang="ts">
const content = ref('<p>Hello world!</p>')
</script>

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

In Collection Forms

<script setup lang="ts">
const state = ref({
  title: '',
  content: '<p></p>',
  excerpt: ''
})
</script>

<template>
  <UForm :state="state" :schema="schema" @submit="handleSubmit">
    <UFormField label="Title" name="title">
      <UInput v-model="state.title" />
    </UFormField>

    <UFormField label="Content" name="content">
      <CroutonEditorSimple v-model="state.content" />
    </UFormField>

    <UFormField label="Excerpt" name="excerpt">
      <UTextarea v-model="state.excerpt" rows="3" />
    </UFormField>

    <CroutonButton :action="action" :loading="loading" />
  </UForm>
</template>

Display Rendered Content

On the frontend, render the HTML safely:

<script setup lang="ts">
const { items: posts } = await useCollectionQuery('blogPosts')
</script>

<template>
  <div v-for="post in posts" :key="post.id">
    <h1>{{ post.title }}</h1>
    <!-- Render HTML (ensure it's sanitized on the backend!) -->
    <div class="prose dark:prose-invert" v-html="post.content" />
  </div>
</template>

<style>
/* Use Tailwind Typography for nice formatting */
.prose {
  @apply max-w-none;
}
</style>
Security Note: Always sanitize HTML on the backend before saving to prevent XSS attacks.

Components

The package provides 4 components, all auto-registered with the CroutonEditor prefix:

CroutonEditorSimple

The main editor component with full WYSIWYG functionality, toolbar, and floating menu.

Props

PropTypeDefaultDescription
modelValuestring''HTML content (use with v-model)

Events

EventPayloadDescription
update:modelValuestringEmitted when content changes (HTML)

Features

  • Text Formatting: Bold, italic, strikethrough
  • Headings: H1, H2, H3
  • Lists: Bullet lists and numbered lists
  • Code Blocks: Syntax highlighting support
  • Blockquotes: Quote formatting
  • Text Colors: 10 preset colors + custom
  • Floating Menu: Context-aware toolbar appears on text selection
  • Bubble Menu: Quick formatting on selection
  • Keyboard Shortcuts: Standard shortcuts (Cmd+B for bold, etc.)

In Forms

<template>
  <UForm :state="formData" @submit="handleSubmit">
    <UFormField label="Title" name="title">
      <UInput v-model="formData.title" />
    </UFormField>

    <UFormField label="Content" name="content">
      <CroutonEditorSimple v-model="formData.content" />
    </UFormField>

    <UButton type="submit">Save Post</UButton>
  </UForm>
</template>

<script setup lang="ts">
const props = defineProps(['action', 'activeItem'])
const { send } = useCrouton()

const formData = ref({
  title: props.activeItem?.title || '',
  content: props.activeItem?.content || '<p></p>'
})

const handleSubmit = () => {
  send(props.action, 'posts', formData.value)
}
</script>

CroutonEditorPreview

A read-only preview component that displays HTML content with proper styling and a modal popup for full view.

Props

PropTypeRequiredDescription
contentstringNoHTML content to display
titlestringNoTitle for the modal preview

Features

  • Thumbnail Preview: Miniature view of content (40px height)
  • Modal Popup: Click to view full content in a modal
  • Prose Styling: Automatic typography styling via Tailwind prose
  • Dark Mode: Automatic theme support

Usage

<template>
  <CroutonEditorPreview
    :content="article.content"
    title="Article Preview"
  />
</template>

In Table Columns

The preview component is automatically used in generated list views for rich text fields:

<!-- Auto-generated in list components -->
<UTable :columns="columns" :data="items">
  <template #content-data="{ row }">
    <CroutonEditorPreview :content="row.original.content" />
  </template>
</UTable>

CroutonEditorToolbar

The floating bubble menu toolbar that appears when text is selected. This component is used internally by CroutonEditorSimple but can be used standalone for custom editor implementations.

Props

PropTypeRequiredDescription
editorEditorYesTipTap editor instance
containerHTMLElement | nullNoContainer element for portal rendering

Features

  • Content Type Selector: Switch between paragraph, headings, lists, code, quotes
  • Text Formatting: Bold, italic, strikethrough buttons
  • Lists: Bullet and numbered list controls
  • Color Picker: 10 preset colors
  • Smart Positioning: Automatically positions near selected text
  • Dropdown Menus: Content type and color selection dropdowns

Content Types

The toolbar provides these content type options:

  • Text (Paragraph)
  • Heading 1
  • Heading 2
  • Heading 3
  • Bullet List
  • Numbered List
  • Code Block
  • Quote (Blockquote)

Color Palette

Available text colors:

  • Default (inherits theme)
  • Gray (#6B7280)
  • Brown (#92400E)
  • Orange (#EA580C)
  • Yellow (#CA8A04)
  • Green (#16A34A)
  • Blue (#2563EB)
  • Purple (#9333EA)
  • Pink (#DB2777)
  • Red (#DC2626)

CroutonEditorCommandsList

A command palette component for quick content block insertion. Used internally by the editor for slash commands.

Props

PropTypeRequiredDescription
itemsSuggestionItem[]YesArray of command items
command(item: SuggestionItem) => voidYesCallback when item is selected

Features

  • Keyboard Navigation: Arrow keys to navigate, Enter to select
  • Auto-scroll: Keeps selected item in view
  • Visual Feedback: Hover and selected states
  • Icon Support: Each item has an icon and description

SuggestionItem Type

interface SuggestionItem {
  name: string        // Display name
  description: string // Helper text
  icon: string       // Icon name (Nuxt Icon format)
  command: () => void // Action to execute
}

Generator Integration

Schema Configuration

Automatically use the editor for specific fields by marking them in your schema:

{
  "collections": {
    "posts": {
      "fields": {
        "title": {
          "type": "string",
          "meta": {
            "label": "Title",
            "required": true
          }
        },
        "content": {
          "type": "text",
          "meta": {
            "label": "Content",
            "component": "CroutonEditorSimple"
          }
        },
        "excerpt": {
          "type": "text",
          "meta": {
            "label": "Excerpt",
            "component": "CroutonEditorSimple"
          }
        }
      }
    }
  }
}

When you generate this collection, the content field will automatically use CroutonEditorSimple.

Generated Form Components

The generator automatically creates form components that use the editor:

<!-- Auto-generated: layers/posts/components/PostsForm.vue -->
<template>
  <UForm :state="formData" @submit="handleSubmit">
    <UFormField label="Title" name="title">
      <UInput v-model="formData.title" />
    </UFormField>

    <UFormField label="Content" name="content">
      <CroutonEditorSimple v-model="formData.content" />
    </UFormField>

    <UFormField label="Excerpt" name="excerpt">
      <CroutonEditorSimple v-model="formData.excerpt" />
    </UFormField>
  </UForm>
</template>

Generated List Components

The generator automatically uses CroutonEditorPreview for rich text columns:

<!-- Auto-generated: layers/posts/components/PostsList.vue -->
<template>
  <UTable :columns="columns" :data="items">
    <template #content-data="{ row }">
      <CroutonEditorPreview :content="row.original.content" />
    </template>
  </UTable>
</template>

TipTap Integration

Included Extensions

The editor package comes pre-configured with these TipTap extensions:

ExtensionPurposeFeatures
StarterKitEssential editingHistory, text, paragraph, heading, bold, italic, strike, code, bullet list, ordered list, blockquote, code block, horizontal rule, hard break
TextStyleText stylingEnables inline style attributes
ColorText colorsCustom text color support

Extension Configuration

The editor is initialized with this configuration:

const editor = useEditor({
  content: props.modelValue,
  extensions: [
    StarterKit,      // Core editing features
    TextStyle,       // Style support
    Color            // Color support
  ],
  editorProps: {
    attributes: {
      class: '',     // Custom classes can be added
    },
  },
  onUpdate: ({ editor }) => {
    emit('update:modelValue', editor.getHTML())
  }
})

Adding Custom Extensions

To use additional TipTap extensions, create a custom editor component:

<script setup lang="ts">
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import TextStyle from '@tiptap/extension-text-style'
import Color from '@tiptap/extension-color'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import Underline from '@tiptap/extension-underline'

const props = defineProps<{
  modelValue?: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

const editor = useEditor({
  content: props.modelValue,
  extensions: [
    StarterKit,
    TextStyle,
    Color,
    Image,        // Add image support
    Link,         // Add link support
    Underline     // Add underline support
  ],
  onUpdate: ({ editor }) => {
    emit('update:modelValue', editor.getHTML())
  }
})
</script>

<template>
  <div>
    <CroutonEditorToolbar :editor="editor" />
    <EditorContent :editor="editor" />
  </div>
</template>

Database Storage

The editor outputs HTML. Store it in a TEXT field in your database:

// Drizzle schema
export const blogPosts = sqliteTable('blog_posts', {
  id: text('id').primaryKey(),
  title: text('title').notNull(),
  content: text('content').notNull(),  // HTML from editor
  excerpt: text('excerpt'),
  createdAt: integer('createdAt', { mode: 'timestamp' })
})

Backend Sanitization

Always sanitize HTML on the backend before saving to prevent XSS attacks:

// server/api/blog-posts/index.post.ts
import sanitizeHtml from 'sanitize-html'

export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  // Sanitize HTML content
  const sanitized = sanitizeHtml(body.content, {
    allowedTags: sanitizeHtml.defaults.allowedTags.concat(['h1', 'h2']),
    allowedAttributes: {
      ...sanitizeHtml.defaults.allowedAttributes,
      '*': ['class']
    }
  })

  // Save to database
  await db.insert(blogPosts).values({
    ...body,
    content: sanitized
  })
})

With Translations

Combine the editor with i18n for multilingual content:

<script setup lang="ts">
const state = ref({
  title: '',
  content: '',
  translations: {}
})

const translatableContent = {
  title: state.value.title,
  content: state.value.content
}
</script>

<template>
  <UForm>
    <!-- Default language -->
    <UFormField label="Title (English)" name="title">
      <UInput v-model="state.title" />
    </UFormField>

    <UFormField label="Content (English)" name="content">
      <CroutonEditorSimple v-model="state.content" />
    </UFormField>

    <!-- Translations with editor support -->
    <CroutonI18nInputWithEditor
      v-model="state.translations"
      :fields="['title', 'content']"
      :default-values="translatableContent"
      :editor-fields="['content']"
    />
  </UForm>
</template>

Customization

Styling

The editor respects your Nuxt UI theme and includes dark mode support. Customize with CSS:

<template>
  <CroutonEditorSimple
    v-model="content"
    class="my-editor"
  />
</template>

<style scoped>
/* Editor container */
.my-editor :deep(.tiptap) {
  min-height: 300px;
  padding: 1rem;
}

/* Headings */
.my-editor :deep(.tiptap h1) {
  font-size: 2rem;
  font-weight: bold;
  margin-bottom: 1rem;
}

/* Dark mode */
.dark .my-editor :deep(.tiptap) {
  background: #1f2937;
  color: #f3f4f6;
}
</style>

Custom Placeholder

<template>
  <CroutonEditorSimple
    v-model="content"
    class="custom-placeholder"
  />
</template>

<style scoped>
.custom-placeholder :deep(.tiptap p.is-empty::before) {
  content: 'Tell your story...';
  color: #9ca3af;
}
</style>

Height Constraints

<template>
  <!-- Fixed height with scroll -->
  <CroutonEditorSimple
    v-model="content"
    class="h-96 overflow-auto"
  />

  <!-- Minimum height -->
  <CroutonEditorSimple
    v-model="content"
    class="min-h-[500px]"
  />

  <!-- Maximum height -->
  <CroutonEditorSimple
    v-model="content"
    class="max-h-[600px] overflow-auto"
  />
</template>

Advanced Usage

Read-Only Mode

Create a read-only editor for display purposes:

<script setup lang="ts">
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'

const props = defineProps<{
  content: string
}>()

const editor = useEditor({
  content: props.content,
  extensions: [StarterKit],
  editable: false,  // Read-only
  editorProps: {
    attributes: {
      class: 'prose prose-sm max-w-none',
    },
  },
})
</script>

<template>
  <EditorContent :editor="editor" />
</template>

Character/Word Count

Track content metrics:

<script setup lang="ts">
import { useEditor, EditorContent } from '@tiptap/vue-3'
import CharacterCount from '@tiptap/extension-character-count'

const editor = useEditor({
  extensions: [
    StarterKit,
    CharacterCount.configure({
      limit: 1000,
    }),
  ],
})

const characterCount = computed(() =>
  editor.value?.storage.characterCount.characters() || 0
)

const wordCount = computed(() =>
  editor.value?.storage.characterCount.words() || 0
)
</script>

<template>
  <div>
    <EditorContent :editor="editor" />
    <div class="text-sm text-gray-500">
      {{ characterCount }} characters / {{ wordCount }} words
    </div>
  </div>
</template>

Markdown Import/Export

Add markdown support:

pnpm add @tiptap/extension-markdown
<script setup lang="ts">
import { useEditor } from '@tiptap/vue-3'
import Markdown from '@tiptap/extension-markdown'

const editor = useEditor({
  extensions: [
    StarterKit,
    Markdown
  ],
})

const getMarkdown = () => {
  return editor.value?.storage.markdown.getMarkdown()
}

const setMarkdown = (markdown: string) => {
  editor.value?.commands.setContent(markdown)
}
</script>

Collaborative Editing

Integrate with Yjs for real-time collaboration:

pnpm add @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor yjs
<script setup lang="ts">
import { useEditor } from '@tiptap/vue-3'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import * as Y from 'yjs'

const ydoc = new Y.Doc()

const editor = useEditor({
  extensions: [
    StarterKit.configure({
      history: false, // Important: disable history for collaboration
    }),
    Collaboration.configure({
      document: ydoc,
    }),
    CollaborationCursor.configure({
      provider: yourWebSocketProvider,
    }),
  ],
})
</script>

Best Practices

Content Validation

Always validate rich text content on the server:

// server/api/posts.post.ts
import { z } from 'zod'
import DOMPurify from 'isomorphic-dompurify'

const postSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().transform((val) => {
    // Sanitize HTML to prevent XSS
    return DOMPurify.sanitize(val, {
      ALLOWED_TAGS: ['p', 'h1', 'h2', 'h3', 'ul', 'ol', 'li', 'strong', 'em', 'code', 'pre', 'blockquote'],
      ALLOWED_ATTR: ['class']
    })
  })
})

export default defineEventHandler(async (event) => {
  const body = await readValidatedBody(event, postSchema.parse)
  // Save to database
})

Performance

For large documents, debounce updates:

<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'

const debouncedUpdate = useDebounceFn((content: string) => {
  emit('update:modelValue', content)
}, 300)

const editor = useEditor({
  onUpdate: ({ editor }) => {
    debouncedUpdate(editor.getHTML())
  }
})
</script>

Accessibility

Ensure keyboard navigation and screen reader support:

<script setup lang="ts">
const editor = useEditor({
  editorProps: {
    attributes: {
      role: 'textbox',
      'aria-label': 'Rich text editor',
      'aria-multiline': 'true',
    },
  },
})
</script>

What to Do

✅ DO:

  • Sanitize HTML on the backend before storing
  • Use the prose class (Tailwind Typography) for consistent rendering
  • Mark editor fields in your schema for automatic generation
  • Keep editor content in a TEXT database field

❌ DON'T:

  • Render unsanitized HTML (XSS risk)
  • Store editor content in a VARCHAR (may truncate)
  • Mix editor HTML with plain text fields
  • Forget to add @nuxt/icon dependency

Troubleshooting

Editor Not Rendering

Problem: Component shows but editor doesn't initialize

Solution:

<script setup lang="ts">
// Ensure editor is properly typed and reactive
const editor = useEditor({
  content: props.modelValue || '',
  // ...
})

// Clean up on unmount
onBeforeUnmount(() => {
  editor.value?.destroy()
})
</script>

Toolbar Not Appearing in Modals

Problem: Bubble menu doesn't show inside UModal or USlideover

Solution: Pass the container ref to the toolbar:

<script setup lang="ts">
const editorContainer = ref<HTMLElement | null>(null)
</script>

<template>
  <UModal>
    <div ref="editorContainer">
      <CroutonEditorToolbar
        :editor="editor"
        :container="editorContainer"
      />
      <EditorContent :editor="editor" />
    </div>
  </UModal>
</template>

Content Not Updating

Problem: v-model binding doesn't sync properly

Solution: Check for two-way binding:

<script setup lang="ts">
// Watch for external changes
watch(() => props.modelValue, (newValue) => {
  if (!editor.value) return

  const currentContent = editor.value.getHTML()

  // Only update if different
  if (currentContent !== newValue) {
    editor.value.commands.setContent(newValue || '', false)
  }
})
</script>

Examples

Blog Post Editor

Basic editor setup:

<script setup lang="ts">
const content = ref('<p></p>')
</script>

<template>
  <UFormField label="Content" name="content">
    <CroutonEditorSimple
      v-model="content"
      class="min-h-[400px]"
    />
  </UFormField>
</template>

Complete blog post form:

<script setup lang="ts">
const { send } = useCrouton()
const formData = ref({
  title: '',
  excerpt: '',
  content: '<p></p>',
  published: false
})

const handleSubmit = () => {
  send('upsert', 'posts', formData.value)
}
</script>

<template>
  <UForm :state="formData" @submit="handleSubmit">
    <UFormField label="Title" name="title" required>
      <UInput v-model="formData.title" />
    </UFormField>

    <UFormField label="Excerpt" name="excerpt">
      <CroutonEditorSimple v-model="formData.excerpt" class="h-32" />
    </UFormField>

    <UFormField label="Content" name="content" required>
      <CroutonEditorSimple v-model="formData.content" class="min-h-[400px]" />
    </UFormField>

    <UButton type="submit" color="primary">Create Post</UButton>
  </UForm>
</template>

Comment System

<script setup lang="ts">
const { user } = useAuth()
const comment = ref('')

const handleSubmit = async () => {
  await $fetch('/api/comments', {
    method: 'POST',
    body: {
      content: comment.value,
      author: user.value.id
    }
  })
  comment.value = ''
}
</script>

<template>
  <div class="space-y-4">
    <div class="border rounded-lg p-4">
      <CroutonEditorSimple
        v-model="comment"
        class="min-h-[150px]"
      />
      <div class="flex justify-end mt-4">
        <UButton @click="handleSubmit">
          Post Comment
        </UButton>
      </div>
    </div>
  </div>
</template>

API Reference

Components

CroutonEditorSimple

interface CroutonEditorSimpleProps {
  modelValue?: string  // HTML content
}

interface CroutonEditorSimpleEmits {
  (event: 'update:modelValue', value: string): void
}

CroutonEditorPreview

interface CroutonEditorPreviewProps {
  content?: string  // HTML content to display
  title?: string    // Modal title
}

CroutonEditorToolbar

interface CroutonEditorToolbarProps {
  editor?: Editor            // TipTap editor instance
  container?: HTMLElement | null  // Portal container
}

CroutonEditorCommandsList

interface SuggestionItem {
  name: string
  description: string
  icon: string
  command: () => void
}

interface CroutonEditorCommandsListProps {
  items: SuggestionItem[]
  command: (item: SuggestionItem) => void
}

interface CroutonEditorCommandsListExposed {
  onKeyDown: (event: KeyboardEvent) => boolean
}

Resources