Rich Text Editor
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-croutonv1.0.1 or higher@nuxt/iconv1.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>
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
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | '' | HTML content (use with v-model) |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue | string | Emitted 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
| Prop | Type | Required | Description |
|---|---|---|---|
content | string | No | HTML content to display |
title | string | No | Title 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
| Prop | Type | Required | Description |
|---|---|---|---|
editor | Editor | Yes | TipTap editor instance |
container | HTMLElement | null | No | Container 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
| Prop | Type | Required | Description |
|---|---|---|---|
items | SuggestionItem[] | Yes | Array of command items |
command | (item: SuggestionItem) => void | Yes | Callback 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:
| Extension | Purpose | Features |
|---|---|---|
| StarterKit | Essential editing | History, text, paragraph, heading, bold, italic, strike, code, bullet list, ordered list, blockquote, code block, horizontal rule, hard break |
| TextStyle | Text styling | Enables inline style attributes |
| Color | Text colors | Custom 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
proseclass (Tailwind Typography) for consistent rendering - Mark editor fields in your schema for automatic generation
- Keep editor content in a
TEXTdatabase 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/icondependency
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
- TipTap Documentation: https://tiptap.dev/docs
- Nuxt UI: https://ui.nuxt.com/
- Package: https://www.npmjs.com/package/@friendlyinternet/nuxt-crouton-editor
Related Documentation
- Internationalization - Multilingual content support
- Custom Components - More form customization options
- Generator Schema Format - Field metadata options