Features

Rich Text Editor

Rich text editing for Nuxt Crouton collections, powered by Nuxt UI's UEditor (TipTap-based).

Rich Text Editor

Rich text editing for Nuxt Crouton collections, powered by Nuxt UI's UEditor (TipTap-based).

Status: Beta

Overview

The @fyit/crouton-editor package is a Nuxt layer that wraps Nuxt UI's UEditor with Crouton-specific defaults, variable insertion support, and live preview functionality.

Features:

  • WYSIWYG editing via Nuxt UI's UEditor
  • 5 components: simple editor, block editor, variable menu, preview, and editor-with-preview
  • Variable insertion ({{variable_name}}) with live preview interpolation
  • Block editor with custom NodeView support and property panels
  • Image upload (toolbar, paste, drag-and-drop)
  • Optional AI translation integration
  • Optional real-time collaboration via Yjs

Quick Start

Installation

pnpm add @fyit/crouton-editor

Peer dependencies:

  • @nuxt/ui v4.3.0+ (provides the TipTap-based UEditor)
  • @fyit/crouton-core
  • @nuxt/icon v1.0.0+

Configuration

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

export default defineNuxtConfig({
  extends: [
    '@fyit/crouton',
    '@fyit/crouton-editor'
  ]
})

Basic Usage

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

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

Components

All components auto-register with the CroutonEditor prefix.

CroutonEditorSimple

The main editor component. Wraps UEditor with a fixed toolbar and a bubble toolbar that appears on text selection.

Props

PropTypeDefaultDescription
modelValuestring | null''Content (v-model)
placeholderstring-Placeholder text
contentType'html' | 'markdown' | 'json''html'Output format
editablebooleantrueEnable/disable editing
autofocusboolean | 'start' | 'end' | 'all' | number-Focus behavior
showToolbarbooleantrueShow the fixed toolbar
showBubbleToolbarbooleantrueShow bubble toolbar on text selection
extensionsany[]-Additional TipTap extensions
enableTranslationAIbooleanfalseShow AI translation button (requires @fyit/crouton-ai)
translationContextTranslationContext-Context for AI translation
onTranslationAccept(text: string) => void-Callback when translation is accepted
enableImageUploadbooleanfalseEnable image upload via toolbar, paste, and drag-drop

Events

EventPayloadDescription
update:modelValuestringContent changed
create{ editor: Editor }Editor instance created
update{ editor: Editor }Content updated
translationAcceptstringTranslation accepted by user

Toolbar

The fixed toolbar includes: undo/redo, headings (H1-H3), lists (bullet/ordered), blockquote, code block, horizontal rule, bold, italic, underline, strikethrough, inline code, highlight, and link.

The bubble toolbar appears on text selection with a "Turn into" dropdown, text formatting marks, and link insertion.

Examples

In a form:

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

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

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

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

With image upload:

<CroutonEditorSimple
  v-model="content"
  enable-image-upload
/>

When enableImageUpload is true, the toolbar shows an "Insert Image" button. Pasting or dragging images into the editor also triggers an upload to /api/upload-image.

With AI translation:

<CroutonEditorSimple
  v-model="content"
  enable-translation-ai
  :translation-context="{
    sourceText: selectedText,
    sourceLanguage: 'en',
    targetLanguage: 'nl',
    fieldType: 'description'
  }"
  @translation-accept="handleTranslation"
/>

CroutonEditorBlocks

A block-based editor built on UEditor with slash command support, custom NodeView blocks, and a property panel system. Designed for structured page content. Supports real-time collaboration via Yjs.

Props

PropTypeDefaultDescription
modelValuestring | TipTapDoc | null''Content (v-model)
placeholderstring-Placeholder text
contentType'html' | 'markdown' | 'json''json'Output format
editablebooleantrueEnable/disable editing
autofocusboolean | 'start' | 'end' | 'all' | number-Focus behavior
extensionsany[]-Custom TipTap block extensions
showToolbarbooleantrueShow the toolbar
showBubbleToolbarbooleantrueShow bubble toolbar on selection
suggestionItemsBlockSuggestionItem[][]Block items for slash command menu
yxmlFragmentY.XmlFragment-Yjs fragment for real-time collaboration
collabProvider{ awareness: any }-Collaboration provider for cursor awareness
collabUser{ name: string; color?: string }-User info for collaboration cursors

Events

EventPayloadDescription
update:modelValuestring | TipTapDocContent changed
create{ editor: Editor }Editor instance created
update{ editor: Editor }Content updated
block:select{ node, pos } | nullBlock selected/deselected
block:edit{ node, pos }Block edit requested (property panel)

Slots

The property-panel slot provides a way to render a custom property panel for editing block attributes:

<CroutonEditorBlocks
  v-model="content"
  :extensions="[MyBlockExtension]"
  :suggestion-items="blockItems"
  content-type="json"
>
  <template #property-panel="{ selectedNode, isOpen, close, updateAttrs, deleteBlock }">
    <USlideover :model-value="isOpen" @update:model-value="!$event && close()">
      <template #content>
        <MyPropertyPanel
          v-if="selectedNode"
          :node="selectedNode.node"
          @update="updateAttrs"
          @delete="deleteBlock"
          @close="close"
        />
      </template>
    </USlideover>
  </template>
</CroutonEditorBlocks>
Slot PropTypeDescription
selectedNode{ node, pos } | nullCurrently selected block
isOpenbooleanProperty panel open state
close() => voidClose the panel
updateAttrs(attrs: Record<string, unknown>) => voidUpdate block attributes
deleteBlock() => voidDelete selected block

BlockSuggestionItem

interface BlockSuggestionItem {
  type: string          // Block type name (e.g., 'heroBlock')
  label: string         // Display label in menu
  description?: string  // Description shown in menu
  icon?: string         // Icon name (e.g., 'i-lucide-layout-template')
  category?: string     // Category for grouping
  command: string       // TipTap command name (e.g., 'insertHeroBlock')
}

CroutonEditorVariables

A variable insertion menu using Nuxt UI's UEditorMentionMenu. Triggered by typing {{ in the editor. Renders variables with optional category grouping.

Props

PropTypeDefaultDescription
editorEditor-TipTap editor instance (from UEditor slot)
variablesEditorVariable[][]Flat list of variables
groupsEditorVariableGroup[]-Grouped variables (alternative to flat list)
charstring'{{'Trigger character

Usage

Pass the editor instance from the UEditor slot:

<CroutonEditorSimple v-model="content">
  <template #default="{ editor }">
    <CroutonEditorVariables
      :editor="editor"
      :variables="emailVariables"
    />
  </template>
</CroutonEditorSimple>

<script setup lang="ts">
const emailVariables = [
  { name: 'customer_name', label: 'Customer Name', sample: 'John Doe' },
  { name: 'booking_date', label: 'Booking Date', sample: 'January 15, 2024' },
]
</script>

Variables with a category property are automatically grouped under category headers.

CroutonEditorPreview

Live preview component with variable interpolation. Displays content with {{variables}} replaced by provided values or sample values from variable definitions.

Props

PropTypeDefaultDescription
contentstring | Record<string, any>-Raw content with {{variables}}, or TipTap JSON document
titlestring'Preview'Panel title
valuesRecord<string, string>-Values for interpolation
variablesEditorVariable[]-Variable definitions (for sample values)
mode'inline' | 'panel''panel'Display mode
expandablebooleantrueAllow expanding to modal
showVariableCountbooleantrueShow variable count in header
containerClassstring-Custom container CSS class
contentClassstring-Custom content area CSS class

Modes

  • panel (default): Full preview with header, variable count indicator, and optional expand-to-modal button.
  • inline: Compact 40px-high thumbnail with an eye button to open a modal for full view.

Usage

<CroutonEditorPreview
  :content="emailBody"
  :variables="emailVariables"
  :values="{ customer_name: 'Jane Smith' }"
  title="Email Preview"
/>

CroutonEditorWithPreview

Combined editor and preview with tab-based layout. Shows an "Editor" tab and a "Preview" tab, with quick-insert variable chips above the editor.

Props

PropTypeDefaultDescription
modelValuestring | null''Content (v-model)
variablesEditorVariable[][]Variables for insertion
previewValuesRecord<string, string>-Override sample values in preview
previewTitlestring'Preview'Preview panel title
contentType'html' | 'markdown' | 'json''html'Output format
placeholderstring-Placeholder text
editablebooleantrueEnable/disable editing
showVariableChipsbooleantrueShow quick-insert variable chips above editor
extensionsany[]-Additional TipTap extensions

Events

EventPayloadDescription
update:modelValuestringContent changed

Usage

<script setup lang="ts">
const emailVariables = [
  { name: 'customer_name', label: 'Customer Name', category: 'customer', sample: 'John Doe' },
  { name: 'booking_date', label: 'Booking Date', category: 'booking', sample: 'Monday, January 15, 2024' },
  { name: 'location_name', label: 'Location', category: 'location', sample: 'Main Office' },
]

const sampleData = {
  customer_name: 'Jane Smith',
  booking_date: 'Tuesday, January 16, 2024',
  location_name: 'Downtown Branch'
}
</script>

<template>
  <CroutonEditorWithPreview
    v-model="emailBody"
    :variables="emailVariables"
    :preview-values="sampleData"
    preview-title="Email Preview"
    placeholder="Write your email template..."
  />
</template>

Composables

useEditorVariables

Utilities for working with {{variable}} placeholders in editor content.

const {
  interpolate,
  extractVariables,
  getSampleValues,
  findUndefinedVariables
} = useEditorVariables()
FunctionSignatureDescription
interpolate(content: string, values: Record<string, string>) => stringReplace {{vars}} with values
extractVariables(content: string) => string[]Get unique variable names from content
getSampleValues(variables: EditorVariable[]) => Record<string, string>Get sample values from variable definitions
findUndefinedVariables(content: string, variables: EditorVariable[]) => string[]Find variables used in content but not defined

Examples:

const { interpolate, extractVariables } = useEditorVariables()

// Interpolate content
const rendered = interpolate(
  'Hello {{customer_name}}!',
  { customer_name: 'John' }
)
// Result: "Hello John!"

// Extract variables
const vars = extractVariables('Hello {{name}}, your booking is on {{date}}')
// Result: ['name', 'date']

Types

import type { EditorVariable, EditorVariableGroup } from '#crouton-editor/types/editor'

interface EditorVariable {
  name: string        // Variable name: "customer_name"
  label: string       // Display label: "Customer Name"
  description?: string // Help text
  icon?: string       // Lucide icon name
  category?: string   // Grouping: "customer", "booking"
  sample?: string     // Sample value for preview
}

interface EditorVariableGroup {
  label: string
  variables: EditorVariable[]
}

Configuration

The layer auto-registers components with the CroutonEditor prefix at priority 1 (overriding stubs from crouton-core) and auto-imports composables:

// nuxt.config.ts (layer internals)
export default defineNuxtConfig({
  components: {
    dirs: [{
      path: 'app/components',
      prefix: 'CroutonEditor',
      global: true,
      priority: 1
    }]
  },
  imports: {
    dirs: ['app/composables']
  },
  alias: {
    '#crouton-editor': 'app'
  }
})

Generator Integration

Mark fields in your collection schema to use the editor automatically:

{
  "collections": {
    "posts": {
      "fields": {
        "content": {
          "type": "text",
          "meta": {
            "label": "Content",
            "component": "CroutonEditorSimple"
          }
        }
      }
    }
  }
}

Generated form components will render CroutonEditorSimple for that field. Generated list components will use CroutonEditorPreview for rich text columns.

Database Storage

The editor outputs HTML by default. Store it in a TEXT column:

export const blogPosts = sqliteTable('blog_posts', {
  id: text('id').primaryKey(),
  title: text('title').notNull(),
  content: text('content').notNull(),
  createdAt: integer('createdAt', { mode: 'timestamp' })
})

Displaying Content

Render editor HTML safely on the frontend:

<template>
  <div class="prose dark:prose-invert" v-html="post.content" />
</template>

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