Editor

Un editor de texto enriquecido basado en TipTap con soporte para tipos de contenido Markdown, HTML y JSON.

xph0000xUso

El componente Editor proporciona una poderosa experiencia de edición de texto enriquecido basada en TipTap. Admite múltiples formatos de contenido (JSON, HTML, Markdown), barras de herramientas personalizables, reordenamiento de bloques de arrastrar y soltar, comandos de barra, menciones, selector de emoji y arquitectura extensible para agregar funcionalidad personalizada.

Este ejemplo muestra un componente Editor listo para producción. Echa un vistazo al código fuente en GitHub.
Si encuentra errores relacionados con prosemiror, como Adding different instances of a keyed plugin, al usar el componente Editor o sus extensiones, es posible que deba agregar paquetes prosemirror a la lista vite.optimizeDeps.include en su archivo nuxt.config.ts.
nuxt.config.ts
export default defineNuxtConfig({
  vite: {
    optimizeDeps: {
      include: [
        '@nuxt/ui > prosemirror-state',
        '@nuxt/ui > prosemirror-transform',
        '@nuxt/ui > prosemirror-model',
        '@nuxt/ui > prosemirror-view',
        '@nuxt/ui > prosemirror-gapcursor'
      ]
    }
  }
})

Contenido

Utilice la directiva v-model para controlar el valor del Editor.

<script setup lang="ts">
const value = ref({
  type: 'doc',
  content: [
    {
      type: 'heading',
      attrs: {
        level: 1
      },
      content: [
        {
          type: 'text',
          text: 'Hello World'
        }
      ]
    },
    {
      type: 'paragraph',
      content: [
        {
          type: 'text',
          text: 'This is a '
        },
        {
          type: 'text',
          marks: [
            {
              type: 'bold'
            }
          ],
          text: 'rich text'
        },
        {
          type: 'text',
          text: ' editor.'
        }
      ]
    }
  ]
})
</script>

<template>
  <UEditor v-model="value" class="w-full min-h-21" />
</template>
<script setup lang="ts">
import { ref } from 'vue'

const value = ref({
  type: 'doc',
  content: [
    {
      type: 'heading',
      attrs: {
        level: 1
      },
      content: [
        {
          type: 'text',
          text: 'Hello World'
        }
      ]
    },
    {
      type: 'paragraph',
      content: [
        {
          type: 'text',
          text: 'This is a '
        },
        {
          type: 'text',
          marks: [
            {
              type: 'bold'
            }
          ],
          text: 'rich text'
        },
        {
          type: 'text',
          text: ' editor.'
        }
      ]
    }
  ]
})
</script>

<template>
  <UEditor v-model="value" class="w-full min-h-21" />
</template>

Contenido

El editor detecta automáticamente el formato de contenido basado en el tipo v-model: las cadenas se tratan como html y los objetos como json.

Puede establecer explícitamente el formato utilizando la prop content-type: json, html o markdown.

<script setup lang="ts">
const value = ref('<h1>Hello World</h1>\n<p>This is a <strong>rich text</strong> editor.</p>\n')
</script>

<template>
  <UEditor v-model="value" content-type="html" class="w-full min-h-21" />
</template>
<script setup lang="ts">
import { ref } from 'vue'

const value = ref('<h1>Hello World</h1>\n<p>This is a <strong>rich text</strong> editor.</p>\n')
</script>

<template>
  <UEditor v-model="value" content-type="html" class="w-full min-h-21" />
</template>

Extensiones

El editor incluye las siguientes extensiones por defecto:

  • xStarterKit-Funciones básicas de edición (negrita, cursiva, encabezados, listas, etc.)
  • Placeholder-Mostrar el texto del marcador de posición (cuando se proporciona el marcador de posición)
  • Image Insertar y mostrar imágenes.
  • Mention-Add @ menciona soporte
  • Markdown-Analice y serialice la reducción de valor (cuando el tipo de contenido es la reducción)
Cada extensión incorporada se puede configurar utilizando su correspondiente prop (starter-kit, placeholder, image, mention, markdown) para personalizar su comportamiento con las opciones de TipTap.

Puede utilizar el soporte extensions para agregar extensiones TipTap adicionales para mejorar las capacidades del Editor:

<script setup lang="ts">
import { Emoji } from '@tiptap/extension-emoji'
import { TextAlign } from '@tiptap/extension-text-align'

const value = ref('<h1>Hello World</h1>\n')
</script>

<template>
  <UEditor
    v-model="value"
    :extensions="[
      Emoji,
      TextAlign.configure({
        types: ['heading', 'paragraph']
      })
    ]"
  />
</template>
Echa un vistazo al ejemplo de carga de imágenes para crear extensiones de TipTap personalizadas.

Placeholder (Edición española)

Utilice el prop placeholder para establecer un texto de marcador de posición que se muestra en párrafos vacíos.

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

<template>
  <UEditor v-model="value" placeholder="Start writing..." class="w-full min-h-7" />
</template>
<script setup lang="ts">
import { ref } from 'vue'

const value = ref('')
</script>

<template>
  <UEditor v-model="value" placeholder="Start writing..." class="w-full min-h-7" />
</template>
La prop placeholder acepta una cadena o un objeto con PlaceholderOptions y una propiedad adicional mode:
  • everyLine: Mostrar marcador de posición en cada línea vacía cuando se enfoca (predeterminado).
  • firstLine: Muestra el marcador de posición sólo en la primera línea cuando el editor está vacío.
<template>
  <UEditor :placeholder="{ placeholder: 'Start writing...', mode: 'firstLine' }" />
</template>
De forma predeterminada, los marcadores de posición solo aparecen en los nodos vacíos de nivel superior. Para mostrar marcadores de posición en elementos anidados, como elementos de lista, establezca includeChildren en true:
<template>
  <UEditor :placeholder="{ placeholder: 'Start writing...', includeChildren: true }" />
</template>
Obtenga más información sobre la extensión Placeholder en la documentación de TipTap .

Kit de inicio

Utilice el accesorio starter-kit para configurar la extensión TipTap StarterKit incorporada que incluye funciones de editor comunes como negrita , cursiva , encabezados , listas , comillas de bloque , bloques de código y más .

<script setup lang="ts">
const value = ref('<h1>Hello World</h1>\n')
</script>

<template>
  <UEditor
    v-model="value"
    :starter-kit="{
      blockquote: false,
      headings: {
        levels: [1, 2, 3, 4]
      },
      dropcursor: {
        color: 'var(--ui-primary)',
        width: 2
      },
      link: {
        openOnClick: false
      }
    }"
  />
</template>
Configure starter-kit a false para un editor de texto plano . Mantiene los nodos esenciales (párrafo , texto , historial) y deshabilita todas las funciones de formato como negrita , cursiva , encabezados , listas , código , blockquote , enlaces y reglas horizontales .
Obtenga más información sobre la extensión StarterKit en la documentación de TipTap .

Controladores

Cuando se agrega una propiedad kind a un elemento EditorToolbar o [EditorSuggestionMenuxph220/docs/components/editor-suggestion-menu) , el controlador correspondiente ejecuta el comando TipTap y administra su estado (activo , deshabilitado , etc.) .

Controladores por defecto

El componente Editor proporciona estos manejadores predeterminados , a los que puede hacer referencia en la barra de herramientas o en los elementos del menú de sugerencias utilizando la propiedad kind :

El HandlerDescripciónEl uso
markx{lang="ts-type"}Alterna las marcas de texto (negrita , itálica , huelga , código , subrayado)Requiere la propiedad mark en item
textAlignx{lang="ts-type"} (Edición española)Alineación del texto (izquierda , centro , derecha , justificar)Requiere la propiedad align en item
heading (Edición española)Toggle niveles de encabezado (1 - 6)Requiere la propiedad level en item
linkx{lang="ts-type"} (Edición española)Añadir , editar o eliminar enlacesIndique la URL si no se proporciona
imagex{lang="ts-type"} (Edición española)Insertar imágenesIndique la URL si no se proporciona
blockquotex{lang="ts-type"}Tagged blockquotes
bulletListx{lang="ts-type"}Lista de Bullets ToggleManeja conversiones de lista
orderedListx{lang="ts-type"} (Edición española)Toggle listas ordenadasManeja conversiones de lista
taskListx{lang="ts-type"} (Edición española)Toggle listas de tareasManeja conversiones de lista
codeBlockx{lang="ts-type"} (Edición española)Toggle Bloques de código
horizontalRulex{lang="ts-type"}Introducir reglas horizontales
paragraphx{lang="ts-type"}Formato de párrafo
undox{lang="ts-type"} (Edición española)Undo el último cambio
redox{lang="ts-type"} (Edición española)Redo último cambio sin hacer
clearFormattingx{lang="ts-type"} (Edición española)Eliminar todo el formatoTrabaja con selección o posición
duplicatex{lang="ts-type"} (Edición española)Duplicar el nodoRequiere la propiedad pos en item
delete (Edición española)Borrar el nodoRequiere la propiedad pos en item
moveUpx{lang="ts-type"}Mover un nodo hacia arribaRequiere la propiedad pos en item
moveDownx{lang="ts-type"}Mover un nodo hacia abajoRequiere la propiedad pos en item
suggestionx{lang="ts-type"}Menú de sugerencias TriggerInsertar el carácter /
mentionx{lang="ts-type"}Trigger MenúInserción de caracteres @
emojix{lang="ts-type"}Inicio Emoji PickerInserción de caracteres :
Los controladores taskList y textAlign solo funcionan cuando se instalan sus respectivas extensiones, ya que no están incluidos en el Editor de forma predeterminada.

A continuación se explica cómo utilizar los controladores predeterminados en la barra de herramientas o elementos del menú de sugerencias:

<script setup lang="ts">
import type { EditorToolbarItem } from '@nuxt/ui'

const value = ref('<h1>Hello World</h1>\n')

const items: EditorToolbarItem[] = [
  { kind: 'mark', mark: 'bold', icon: 'i-lucide-bold' },
  { kind: 'mark', mark: 'italic', icon: 'i-lucide-italic' },
  { kind: 'heading', level: 1, icon: 'i-lucide-heading-1' },
  { kind: 'heading', level: 2, icon: 'i-lucide-heading-2' },
  { kind: 'textAlign', align: 'left', icon: 'i-lucide-align-left' },
  { kind: 'textAlign', align: 'center', icon: 'i-lucide-align-center' },
  { kind: 'bulletList', icon: 'i-lucide-list' },
  { kind: 'orderedList', icon: 'i-lucide-list-ordered' },
  { kind: 'blockquote', icon: 'i-lucide-quote' },
  { kind: 'link', icon: 'i-lucide-link' }
]
</script>

<template>
  <UEditor v-slot="{ editor }" v-model="value">
    <UEditorToolbar :editor="editor" :items="items" />
  </UEditor>
</template>

Custom manejadores

Utilice el prop handlers para extender o anular los controladores predeterminados. Los controladores personalizados se fusionan con los controladores predeterminados, de modo que puede agregar nuevas acciones o modificar el comportamiento existente.

Cada manejador implementa la interfaz EditorHandler:

interface EditorHandler {
  /* Checks if the command can be executed in the current editor state */
  canExecute: (editor: Editor, item?: any) => boolean
  /* Executes the command and returns a Tiptap chain */
  execute: (editor: Editor, item?: any) => any
  /* Determines if the item should appear active (used for toggle states) */
  isActive: (editor: Editor, item?: any) => boolean
  /* Optional additional check to disable the item (combined with `canExecute`) */
  isDisabled?: (editor: Editor, item?: any) => boolean
}

Aquí hay un ejemplo de creación de controladores personalizados:

<script setup lang="ts">
import type { Editor } from '@tiptap/vue-3'
import type { EditorCustomHandlers, EditorToolbarItem } from '@nuxt/ui'

const value = ref('<h1>Hello World</h1>\n')

const customHandlers = {
  highlight: {
    canExecute: (editor: Editor) => editor.can().toggleHighlight(),
    execute: (editor: Editor) => editor.chain().focus().toggleHighlight(),
    isActive: (editor: Editor) => editor.isActive('highlight'),
    isDisabled: (editor: Editor) => !editor.isEditable
  }
} satisfies EditorCustomHandlers

const items = [
  // Built-in handler
  { kind: 'mark', mark: 'bold', icon: 'i-lucide-bold' },
  // Custom handler
  { kind: 'highlight', icon: 'i-lucide-highlighter' }
] satisfies EditorToolbarItem<typeof customHandlers>[]
</script>

<template>
  <UEditor v-slot="{ editor }" v-model="value" :handlers="customHandlers">
    <UEditorToolbar :editor="editor" :items="items" />
  </UEditor>
</template>
Consulte el ejemplo de carga de imágenes para una implementación completa con controladores personalizados.

Ejemplos

Echa un vistazo al código fuente de nuestro Editor template en GitHub para un ejemplo de la vida real.

Con barra de herramientas

Puede usar el componente EditorToolbar para agregar una barra de herramientas fixed, bubble o floating al Editor con acciones de formato comunes.

<script setup lang="ts">
import type { EditorToolbarItem } from '@nuxt/ui'

const value = ref(`# Toolbar

Select some text to see the formatting toolbar appear above your selection.`)

const items: EditorToolbarItem[][] = [
  [
    {
      icon: 'i-lucide-heading',
      tooltip: { text: 'Headings' },
      content: {
        align: 'start'
      },
      items: [
        {
          kind: 'heading',
          level: 1,
          icon: 'i-lucide-heading-1',
          label: 'Heading 1'
        },
        {
          kind: 'heading',
          level: 2,
          icon: 'i-lucide-heading-2',
          label: 'Heading 2'
        },
        {
          kind: 'heading',
          level: 3,
          icon: 'i-lucide-heading-3',
          label: 'Heading 3'
        },
        {
          kind: 'heading',
          level: 4,
          icon: 'i-lucide-heading-4',
          label: 'Heading 4'
        }
      ]
    }
  ],
  [
    {
      kind: 'mark',
      mark: 'bold',
      icon: 'i-lucide-bold',
      tooltip: { text: 'Bold' }
    },
    {
      kind: 'mark',
      mark: 'italic',
      icon: 'i-lucide-italic',
      tooltip: { text: 'Italic' }
    },
    {
      kind: 'mark',
      mark: 'underline',
      icon: 'i-lucide-underline',
      tooltip: { text: 'Underline' }
    },
    {
      kind: 'mark',
      mark: 'strike',
      icon: 'i-lucide-strikethrough',
      tooltip: { text: 'Strikethrough' }
    },
    {
      kind: 'mark',
      mark: 'code',
      icon: 'i-lucide-code',
      tooltip: { text: 'Code' }
    }
  ]
]
</script>

<template>
  <UEditor v-slot="{ editor }" v-model="value" content-type="markdown" class="w-full min-h-21">
    <UEditorToolbar :editor="editor" :items="items" layout="bubble" />
  </UEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui'

const value = ref(`# Toolbar

Select some text to see the formatting toolbar appear above your selection.`)

const items: EditorToolbarItem[][] = [
  [
    {
      icon: 'i-lucide-heading',
      tooltip: { text: 'Headings' },
      content: {
        align: 'start'
      },
      items: [
        {
          kind: 'heading',
          level: 1,
          icon: 'i-lucide-heading-1',
          label: 'Heading 1'
        },
        {
          kind: 'heading',
          level: 2,
          icon: 'i-lucide-heading-2',
          label: 'Heading 2'
        },
        {
          kind: 'heading',
          level: 3,
          icon: 'i-lucide-heading-3',
          label: 'Heading 3'
        },
        {
          kind: 'heading',
          level: 4,
          icon: 'i-lucide-heading-4',
          label: 'Heading 4'
        }
      ]
    }
  ],
  [
    {
      kind: 'mark',
      mark: 'bold',
      icon: 'i-lucide-bold',
      tooltip: { text: 'Bold' }
    },
    {
      kind: 'mark',
      mark: 'italic',
      icon: 'i-lucide-italic',
      tooltip: { text: 'Italic' }
    },
    {
      kind: 'mark',
      mark: 'underline',
      icon: 'i-lucide-underline',
      tooltip: { text: 'Underline' }
    },
    {
      kind: 'mark',
      mark: 'strike',
      icon: 'i-lucide-strikethrough',
      tooltip: { text: 'Strikethrough' }
    },
    {
      kind: 'mark',
      mark: 'code',
      icon: 'i-lucide-code',
      tooltip: { text: 'Code' }
    }
  ]
]
</script>

<template>
  <UEditor v-slot="{ editor }" v-model="value" content-type="markdown" class="w-full min-h-21">
    <UEditorToolbar :editor="editor" :items="items" layout="bubble" />
  </UEditor>
</template>

Con control de arrastre

Puede usar el componente EditorDragHandle para agregar un controlador arrastrable para reordenar bloques.

<script setup lang="ts">
const value = ref(`# Drag Handle

Hover over the left side of this block to see the drag handle appear and reorder blocks.`)
</script>

<template>
  <UEditor v-slot="{ editor }" v-model="value" content-type="markdown" class="w-full min-h-21">
    <UEditorDragHandle :editor="editor" />
  </UEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'

const value = ref(`# Drag Handle

Hover over the left side of this block to see the drag handle appear and reorder blocks.`)
</script>

<template>
  <UEditor v-slot="{ editor }" v-model="value" content-type="markdown" class="w-full min-h-21">
    <UEditorDragHandle :editor="editor" />
  </UEditor>
</template>

Con menú de sugerencias

Puede utilizar el componente EditorSuggestionMenu para agregar comandos de barra para formatear e insertar rápidamente.

<script setup lang="ts">
import type { EditorSuggestionMenuItem } from '@nuxt/ui'

const value = ref(`# Suggestion Menu

Type / to open the suggestion menu and browse available formatting commands.`)

const items: EditorSuggestionMenuItem[][] = [
  [
    {
      type: 'label',
      label: 'Text'
    },
    {
      kind: 'paragraph',
      label: 'Paragraph',
      icon: 'i-lucide-type'
    },
    {
      kind: 'heading',
      level: 1,
      label: 'Heading 1',
      icon: 'i-lucide-heading-1'
    },
    {
      kind: 'heading',
      level: 2,
      label: 'Heading 2',
      icon: 'i-lucide-heading-2'
    },
    {
      kind: 'heading',
      level: 3,
      label: 'Heading 3',
      icon: 'i-lucide-heading-3'
    }
  ],
  [
    {
      type: 'label',
      label: 'Lists'
    },
    {
      kind: 'bulletList',
      label: 'Bullet List',
      icon: 'i-lucide-list'
    },
    {
      kind: 'orderedList',
      label: 'Numbered List',
      icon: 'i-lucide-list-ordered'
    }
  ],
  [
    {
      type: 'label',
      label: 'Insert'
    },
    {
      kind: 'blockquote',
      label: 'Blockquote',
      icon: 'i-lucide-text-quote'
    },
    {
      kind: 'codeBlock',
      label: 'Code Block',
      icon: 'i-lucide-square-code'
    },
    {
      kind: 'horizontalRule',
      label: 'Divider',
      icon: 'i-lucide-separator-horizontal'
    }
  ]
]

// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined
</script>

<template>
  <UEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    placeholder="Type / for commands..."
    class="w-full min-h-21"
  >
    <UEditorSuggestionMenu :editor="editor" :items="items" :append-to="appendToBody" />
  </UEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorSuggestionMenuItem } from '@nuxt/ui'

const value = ref(`# Suggestion Menu

Type / to open the suggestion menu and browse available formatting commands.`)

const items: EditorSuggestionMenuItem[][] = [
  [
    {
      type: 'label',
      label: 'Text'
    },
    {
      kind: 'paragraph',
      label: 'Paragraph',
      icon: 'i-lucide-type'
    },
    {
      kind: 'heading',
      level: 1,
      label: 'Heading 1',
      icon: 'i-lucide-heading-1'
    },
    {
      kind: 'heading',
      level: 2,
      label: 'Heading 2',
      icon: 'i-lucide-heading-2'
    },
    {
      kind: 'heading',
      level: 3,
      label: 'Heading 3',
      icon: 'i-lucide-heading-3'
    }
  ],
  [
    {
      type: 'label',
      label: 'Lists'
    },
    {
      kind: 'bulletList',
      label: 'Bullet List',
      icon: 'i-lucide-list'
    },
    {
      kind: 'orderedList',
      label: 'Numbered List',
      icon: 'i-lucide-list-ordered'
    }
  ],
  [
    {
      type: 'label',
      label: 'Insert'
    },
    {
      kind: 'blockquote',
      label: 'Blockquote',
      icon: 'i-lucide-text-quote'
    },
    {
      kind: 'codeBlock',
      label: 'Code Block',
      icon: 'i-lucide-square-code'
    },
    {
      kind: 'horizontalRule',
      label: 'Divider',
      icon: 'i-lucide-separator-horizontal'
    }
  ]
]

// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined
</script>

<template>
  <UEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    placeholder="Type / for commands..."
    class="w-full min-h-21"
  >
    <UEditorSuggestionMenu :editor="editor" :items="items" :append-to="appendToBody" />
  </UEditor>
</template>

Menú de menciones

Puede usar el componente EditorMentionMenu para agregar menciones @ para etiquetar usuarios o entidades.

<script setup lang="ts">
import type { EditorMentionMenuItem } from '@nuxt/ui'

const value = ref(`# Mention Menu

Type @ to mention someone and select from the list of available users.`)

const items: EditorMentionMenuItem[] = [
  {
    label: 'benjamincanac',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/739984?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'atinux',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/904724?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'danielroe',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/28706372?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'pi0',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/5158436?v=4',
      loading: 'lazy' as const
    }
  }
]

// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined
</script>

<template>
  <UEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    placeholder="Type @ to mention someone..."
    class="w-full min-h-21"
  >
    <UEditorMentionMenu :editor="editor" :items="items" :append-to="appendToBody" />
  </UEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorMentionMenuItem } from '@nuxt/ui'

const value = ref(`# Mention Menu

Type @ to mention someone and select from the list of available users.`)

const items: EditorMentionMenuItem[] = [
  {
    label: 'benjamincanac',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/739984?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'atinux',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/904724?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'danielroe',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/28706372?v=4',
      loading: 'lazy' as const
    }
  },
  {
    label: 'pi0',
    avatar: {
      src: 'https://avatars.githubusercontent.com/u/5158436?v=4',
      loading: 'lazy' as const
    }
  }
]

// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined
</script>

<template>
  <UEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    placeholder="Type @ to mention someone..."
    class="w-full min-h-21"
  >
    <UEditorMentionMenu :editor="editor" :items="items" :append-to="appendToBody" />
  </UEditor>
</template>

Con menú de emoji

Puede usar el componente EditorEmojiMenu para agregar soporte para el selector de emojis.

<script setup lang="ts">
import type { EditorEmojiMenuItem } from '@nuxt/ui'
import { Emoji, gitHubEmojis } from '@tiptap/extension-emoji'

const value = ref(`# Emoji Menu

Type : to insert emojis and select from the list of available emojis.`)

const items: EditorEmojiMenuItem[] = gitHubEmojis.filter(
  (emoji) => !emoji.name.startsWith('regional_indicator_')
)

// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined
</script>

<template>
  <UEditor
    v-slot="{ editor }"
    v-model="value"
    :extensions="[Emoji]"
    content-type="markdown"
    placeholder="Type : to add emojis..."
    class="w-full min-h-21"
  >
    <UEditorEmojiMenu :editor="editor" :items="items" :append-to="appendToBody" />
  </UEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorEmojiMenuItem } from '@nuxt/ui'
import { Emoji, gitHubEmojis } from '@tiptap/extension-emoji'

const value = ref(`# Emoji Menu

Type : to insert emojis and select from the list of available emojis.`)

const items: EditorEmojiMenuItem[] = gitHubEmojis.filter(
  (emoji) => !emoji.name.startsWith('regional_indicator_')
)

// SSR-safe function to append menus to body (avoids z-index issues in docs)
const appendToBody = false ? () => document.body : undefined
</script>

<template>
  <UEditor
    v-slot="{ editor }"
    v-model="value"
    :extensions="[Emoji]"
    content-type="markdown"
    placeholder="Type : to add emojis..."
    class="w-full min-h-21"
  >
    <UEditorEmojiMenu :editor="editor" :items="items" :append-to="appendToBody" />
  </UEditor>
</template>

With image upload

En este ejemplo se muestra cómo crear una función de carga de imágenes utilizando el prop extensions para registrar un nodo TipTap personalizado y el prop handlers para definir cómo el botón de la barra de herramientas activa el flujo de carga.

  1. Crear un componente de Vue que utilice el componente FileUpload:
EditorImageUploadNode.vue
<script setup lang="ts">
import type { NodeViewProps } from '@tiptap/vue-3'
import { NodeViewWrapper } from '@tiptap/vue-3'

const props = defineProps<NodeViewProps>()

const file = ref<File | null>(null)
const loading = ref(false)

watch(file, async (newFile) => {
  if (!newFile) return

  loading.value = true

  const reader = new FileReader()
  reader.onload = async (e) => {
    const dataUrl = e.target?.result as string
    if (!dataUrl) {
      loading.value = false
      return
    }

    // Simulate upload delay
    await new Promise(resolve => setTimeout(resolve, 1000))

    const pos = props.getPos()
    if (typeof pos !== 'number') {
      loading.value = false
      return
    }

    props.editor
      .chain()
      .focus()
      .deleteRange({ from: pos, to: pos + 1 })
      .setImage({ src: dataUrl })
      .run()

    loading.value = false
  }
  reader.readAsDataURL(newFile)
})
</script>

<template>
  <NodeViewWrapper>
    <UFileUpload
      v-model="file"
      accept="image/*"
      label="Upload an image"
      description="SVG, PNG, JPG or GIF (max. 2MB)"
      :preview="false"
      class="min-h-48"
    >
      <template #leading>
        <UAvatar
          :icon="loading ? 'i-lucide-loader-circle' : 'i-lucide-image'"
          size="xl"
          :ui="{ icon: [loading && 'animate-spin'] }"
        />
      </template>
    </UFileUpload>
  </NodeViewWrapper>
</template>
EditorImageUploadNode.vue
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { NodeViewProps } from '@tiptap/vue-3'
import { NodeViewWrapper } from '@tiptap/vue-3'

const props = defineProps<NodeViewProps>()

const file = ref<File | null>(null)
const loading = ref(false)

watch(file, async (newFile) => {
  if (!newFile) return

  loading.value = true

  const reader = new FileReader()
  reader.onload = async (e) => {
    const dataUrl = e.target?.result as string
    if (!dataUrl) {
      loading.value = false
      return
    }

    // Simulate upload delay
    await new Promise(resolve => setTimeout(resolve, 1000))

    const pos = props.getPos()
    if (typeof pos !== 'number') {
      loading.value = false
      return
    }

    props.editor
      .chain()
      .focus()
      .deleteRange({ from: pos, to: pos + 1 })
      .setImage({ src: dataUrl })
      .run()

    loading.value = false
  }
  reader.readAsDataURL(newFile)
})
</script>

<template>
  <NodeViewWrapper>
    <UFileUpload
      v-model="file"
      accept="image/*"
      label="Upload an image"
      description="SVG, PNG, JPG or GIF (max. 2MB)"
      :preview="false"
      class="min-h-48"
    >
      <template #leading>
        <UAvatar
          :icon="loading ? 'i-lucide-loader-circle' : 'i-lucide-image'"
          size="xl"
          :ui="{ icon: [loading && 'animate-spin'] }"
        />
      </template>
    </UFileUpload>
  </NodeViewWrapper>
</template>
  1. Crear una extensión TipTap personalizada para registrar el nodo:
EditorImageUploadExtension.ts
import { Node, mergeAttributes } from '@tiptap/core'
import type { CommandProps, NodeViewRenderer } from '@tiptap/core'
import { VueNodeViewRenderer } from '@tiptap/vue-3'
import ImageUploadNodeComponent from './EditorImageUploadNode.vue'

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    imageUpload: {
      insertImageUpload: () => ReturnType
    }
  }
}

export const ImageUpload = Node.create({
  name: 'imageUpload',
  group: 'block',
  atom: true,
  draggable: true,
  addAttributes() {
    return {}
  },
  parseHTML() {
    return [{
      tag: 'div[data-type="image-upload"]'
    }]
  },
  renderHTML({ HTMLAttributes }) {
    return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'image-upload' })]
  },
  addNodeView(): NodeViewRenderer {
    return VueNodeViewRenderer(ImageUploadNodeComponent)
  },
  addCommands() {
    return {
      insertImageUpload: () => ({ commands }: CommandProps) => {
        return commands.insertContent({ type: this.name })
      }
    }
  }
})

export default ImageUpload
  1. Use la extensión personalizada en el Editor:
<script setup lang="ts">
import type { EditorCustomHandlers, EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/vue-3'
import { ImageUpload } from './EditorImageUploadExtension'

const value = ref(`# Image Upload

This editor demonstrates how to create a custom TipTap extension with handlers. Click the image button in the toolbar to upload a file — it will show a custom [FileUpload](/docs/components/file-upload) interface before inserting the image.

Try uploading an image below:

`)

const customHandlers = {
  imageUpload: {
    canExecute: (editor: Editor) => editor.can().insertContent({ type: 'imageUpload' }),
    execute: (editor: Editor) => editor.chain().focus().insertContent({ type: 'imageUpload' }),
    isActive: (editor: Editor) => editor.isActive('imageUpload'),
    isDisabled: undefined
  }
} satisfies EditorCustomHandlers

const items = [
  [
    {
      kind: 'imageUpload',
      icon: 'i-lucide-image',
      label: 'Add image',
      variant: 'soft'
    }
  ],
  [
    {
      icon: 'i-lucide-heading',
      content: {
        align: 'start'
      },
      items: [
        {
          kind: 'heading',
          level: 1,
          icon: 'i-lucide-heading-1',
          label: 'Heading 1'
        },
        {
          kind: 'heading',
          level: 2,
          icon: 'i-lucide-heading-2',
          label: 'Heading 2'
        },
        {
          kind: 'heading',
          level: 3,
          icon: 'i-lucide-heading-3',
          label: 'Heading 3'
        },
        {
          kind: 'heading',
          level: 4,
          icon: 'i-lucide-heading-4',
          label: 'Heading 4'
        }
      ]
    }
  ],
  [
    {
      kind: 'mark',
      mark: 'bold',
      icon: 'i-lucide-bold'
    },
    {
      kind: 'mark',
      mark: 'italic',
      icon: 'i-lucide-italic'
    },
    {
      kind: 'mark',
      mark: 'underline',
      icon: 'i-lucide-underline'
    },
    {
      kind: 'mark',
      mark: 'strike',
      icon: 'i-lucide-strikethrough'
    },
    {
      kind: 'mark',
      mark: 'code',
      icon: 'i-lucide-code'
    }
  ]
] satisfies EditorToolbarItem<typeof customHandlers>[][]
</script>

<template>
  <UEditor
    v-slot="{ editor }"
    v-model="value"
    :extensions="[ImageUpload]"
    :handlers="customHandlers"
    content-type="markdown"
    :ui="{ base: 'p-8 sm:px-16' }"
    class="w-full min-h-74"
  >
    <UEditorToolbar
      :editor="editor"
      :items="items"
      class="border-b border-muted py-2 px-8 sm:px-16 overflow-x-auto"
    />
  </UEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorCustomHandlers, EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/vue-3'
import { ImageUpload } from './EditorImageUploadExtension'

const value = ref(`# Image Upload

This editor demonstrates how to create a custom TipTap extension with handlers. Click the image button in the toolbar to upload a file — it will show a custom [FileUpload](/docs/components/file-upload) interface before inserting the image.

Try uploading an image below:

`)

const customHandlers = {
  imageUpload: {
    canExecute: (editor: Editor) => editor.can().insertContent({ type: 'imageUpload' }),
    execute: (editor: Editor) => editor.chain().focus().insertContent({ type: 'imageUpload' }),
    isActive: (editor: Editor) => editor.isActive('imageUpload'),
    isDisabled: undefined
  }
} satisfies EditorCustomHandlers

const items = [
  [
    {
      kind: 'imageUpload',
      icon: 'i-lucide-image',
      label: 'Add image',
      variant: 'soft'
    }
  ],
  [
    {
      icon: 'i-lucide-heading',
      content: {
        align: 'start'
      },
      items: [
        {
          kind: 'heading',
          level: 1,
          icon: 'i-lucide-heading-1',
          label: 'Heading 1'
        },
        {
          kind: 'heading',
          level: 2,
          icon: 'i-lucide-heading-2',
          label: 'Heading 2'
        },
        {
          kind: 'heading',
          level: 3,
          icon: 'i-lucide-heading-3',
          label: 'Heading 3'
        },
        {
          kind: 'heading',
          level: 4,
          icon: 'i-lucide-heading-4',
          label: 'Heading 4'
        }
      ]
    }
  ],
  [
    {
      kind: 'mark',
      mark: 'bold',
      icon: 'i-lucide-bold'
    },
    {
      kind: 'mark',
      mark: 'italic',
      icon: 'i-lucide-italic'
    },
    {
      kind: 'mark',
      mark: 'underline',
      icon: 'i-lucide-underline'
    },
    {
      kind: 'mark',
      mark: 'strike',
      icon: 'i-lucide-strikethrough'
    },
    {
      kind: 'mark',
      mark: 'code',
      icon: 'i-lucide-code'
    }
  ]
] satisfies EditorToolbarItem<typeof customHandlers>[][]
</script>

<template>
  <UEditor
    v-slot="{ editor }"
    v-model="value"
    :extensions="[ImageUpload]"
    :handlers="customHandlers"
    content-type="markdown"
    :ui="{ base: 'p-8 sm:px-16' }"
    class="w-full min-h-74"
  >
    <UEditorToolbar
      :editor="editor"
      :items="items"
      class="border-b border-muted py-2 px-8 sm:px-16 overflow-x-auto"
    />
  </UEditor>
</template>
Obtenga más información sobre la creación de extensiones personalizadas en la documentación de TipTap.

Con finalización de IA

Este ejemplo muestra cómo agregar funciones impulsadas por IA al Editor utilizando el SDK](https://ai-sdk.dev/) [Vercel AI, específicamente el composable useCompletionxph45xxph45x) para completar el texto en streaming, Combinado con el gateway de Vercel AI para acceder a los modelos de IA a través de un punto final centralizado. acciones (corregir gramática, extender, reducir, simplificar, traducir, etc.).

Primero debe instalar estas dependencias para usar este ejemplo:
pnpm add ai @ai-sdk/gateway @ai-sdk/vue
  1. Crear una extensión TipTap personalizada que maneje sugerencias de texto fantasma en línea:
EditorCompletionExtension.ts
import { Extension } from '@tiptap/core'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import type { Editor } from '@tiptap/vue-3'
import { useDebounceFn } from '@vueuse/core'

export interface CompletionOptions {
  /**
   * Debounce delay in ms before triggering completion
   * @defaultValue 250
   */
  debounce?: number
  /**
   * Whether to automatically trigger completion while typing
   * @defaultValue false
   */
  autoTrigger?: boolean
  /**
   * Characters that should prevent completion from triggering
   * @defaultValue ['/', ':', '@']
   */
  triggerCharacters?: string[]
  /**
   * Called when completion should be triggered, receives the editor instance
   */
  onTrigger?: (editor: Editor) => void
  /**
   * Called when suggestion is accepted
   */
  onAccept?: () => void
  /**
   * Called when suggestion is dismissed
   */
  onDismiss?: () => void
}

export interface CompletionStorage {
  suggestion: string
  position: number | undefined
  visible: boolean
  debouncedTrigger: ((editor: Editor) => void) | null
  setSuggestion: (text: string) => void
  clearSuggestion: () => void
}

export const completionPluginKey = new PluginKey('completion')

export const Completion = Extension.create<CompletionOptions, CompletionStorage>({
  name: 'completion',

  addOptions() {
    return {
      debounce: 250,
      autoTrigger: false,
      triggerCharacters: ['/', ':', '@'],
      onTrigger: undefined,
      onAccept: undefined,
      onDismiss: undefined
    }
  },

  addStorage() {
    return {
      suggestion: '',
      position: undefined as number | undefined,
      visible: false,
      debouncedTrigger: null as ((editor: Editor) => void) | null,
      setSuggestion(text: string) {
        this.suggestion = text
      },
      clearSuggestion() {
        this.suggestion = ''
        this.position = undefined
        this.visible = false
      }
    }
  },

  addProseMirrorPlugins() {
    const storage = this.storage

    return [
      new Plugin({
        key: completionPluginKey,
        props: {
          decorations(state) {
            if (!storage.visible || !storage.suggestion || storage.position === undefined) {
              return DecorationSet.empty
            }

            const widget = Decoration.widget(storage.position, () => {
              const span = document.createElement('span')
              span.className = 'completion-suggestion'
              span.textContent = storage.suggestion
              span.style.cssText = 'color: var(--ui-text-muted); opacity: 0.6; pointer-events: none;'
              return span
            }, { side: 1 })

            return DecorationSet.create(state.doc, [widget])
          }
        }
      })
    ]
  },

  addKeyboardShortcuts() {
    return {
      'Mod-j': ({ editor }) => {
        // Clear any existing suggestion first to avoid flickering
        if (this.storage.visible) {
          this.storage.clearSuggestion()
          this.options.onDismiss?.()
        }
        // Manually trigger completion
        this.storage.debouncedTrigger?.(editor as Editor)
        return true
      },
      'Tab': ({ editor }) => {
        if (!this.storage.visible || !this.storage.suggestion || this.storage.position === undefined) {
          return false
        }

        // Store values before clearing
        const suggestion = this.storage.suggestion
        const position = this.storage.position

        // Clear suggestion first
        this.storage.clearSuggestion()

        // Force decoration update
        editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))

        // Insert the suggestion text
        editor.chain().focus().insertContentAt(position, suggestion).run()

        this.options.onAccept?.()
        return true
      },
      'Escape': ({ editor }) => {
        if (this.storage.visible) {
          this.storage.clearSuggestion()
          // Force decoration update
          editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))
          this.options.onDismiss?.()
          return true
        }
        return false
      }
    }
  },

  onUpdate({ editor }) {
    // Clear suggestion on any edit
    if (this.storage.visible) {
      this.storage.clearSuggestion()
      // Force decoration update
      editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))
      this.options.onDismiss?.()
    }

    // Debounced trigger check (only if autoTrigger is enabled)
    if (this.options.autoTrigger) {
      this.storage.debouncedTrigger?.(editor as Editor)
    }
  },

  onSelectionUpdate({ editor }) {
    if (this.storage.visible) {
      this.storage.clearSuggestion()
      // Force decoration update
      editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))
      this.options.onDismiss?.()
    }
  },

  onCreate() {
    const storage = this.storage
    const options = this.options

    // Create debounced trigger function for this instance
    this.storage.debouncedTrigger = useDebounceFn((editor: Editor) => {
      if (!options.onTrigger) return

      const { state } = editor
      const { selection } = state
      const { $from } = selection

      // Only suggest at end of block with content
      const isAtEndOfBlock = $from.parentOffset === $from.parent.content.size
      const hasContent = $from.parent.textContent.trim().length > 0
      const textContent = $from.parent.textContent

      // Don't trigger if sentence is complete (ends with punctuation)
      const endsWithPunctuation = /[.!?]\s*$/.test(textContent)

      // Don't trigger if text ends with trigger characters
      const triggerChars = options.triggerCharacters || []
      const endsWithTrigger = triggerChars.some(char => textContent.endsWith(char))

      if (!isAtEndOfBlock || !hasContent || endsWithPunctuation || endsWithTrigger) {
        return
      }

      // Set position and mark as visible
      storage.position = selection.from
      storage.visible = true

      // Pass editor to let the handler extract content (e.g., as markdown)
      options.onTrigger(editor)
    }, options.debounce || 250)
  },

  onDestroy() {
    this.storage.debouncedTrigger = null
  }
})

export default Completion
  1. Crear un composable que gestione el estado de finalización de la IA y los manejadores:
useEditorCompletion.ts
import { useCompletion } from '@ai-sdk/vue'
import type { Editor } from '@tiptap/vue-3'
import { Completion } from './EditorCompletionExtension'
import type { CompletionStorage } from './EditorCompletionExtension'

type CompletionMode = 'continue' | 'fix' | 'extend' | 'reduce' | 'simplify' | 'summarize' | 'translate'

export interface UseEditorCompletionOptions {
  api?: string
}

export function useEditorCompletion(editorRef: Ref<{ editor: Editor | undefined } | null | undefined>, options: UseEditorCompletionOptions = {}) {
  // State for direct insertion/transform mode
  const insertState = ref<{
    pos: number
    deleteRange?: { from: number, to: number }
  }>()
  const mode = ref<CompletionMode>('continue')
  const language = ref<string>()

  // Helper to get completion storage
  function getCompletionStorage() {
    const storage = editorRef.value?.editor?.storage as Record<string, CompletionStorage> | undefined
    return storage?.completion
  }

  const { completion, complete, isLoading, stop, setCompletion } = useCompletion({
    api: options.api || '/api/completion',
    streamProtocol: 'text',
    body: computed(() => ({
      mode: mode.value,
      language: language.value
    })),
    onFinish: (_prompt, completionText) => {
      // For inline suggestion mode, don't clear - let user accept with Tab
      const storage = getCompletionStorage()
      if (mode.value === 'continue' && storage?.visible) {
        return
      }

      // For transform modes, insert the full completion with markdown parsing
      const transformModes = ['fix', 'extend', 'reduce', 'simplify', 'summarize', 'translate']
      if (transformModes.includes(mode.value) && insertState.value && completionText) {
        const editor = editorRef.value?.editor
        if (editor) {
          // Delete the original selection if not already done
          if (insertState.value.deleteRange) {
            editor.chain()
              .focus()
              .deleteRange(insertState.value.deleteRange)
              .run()
          }

          // Insert with markdown parsing
          editor.chain()
            .focus()
            .insertContentAt(insertState.value.pos, completionText, { contentType: 'markdown' })
            .run()
        }
      }

      insertState.value = undefined
    },
    onError: (error) => {
      console.error('AI completion error:', error)
      insertState.value = undefined
      getCompletionStorage()?.clearSuggestion()
    }
  })

  // Watch completion for inline suggestion updates
  watch(completion, (newCompletion, oldCompletion) => {
    const editor = editorRef.value?.editor
    if (!editor || !newCompletion) return

    const storage = getCompletionStorage()
    if (storage?.visible) {
      // Update inline suggestion
      // Add space prefix if needed (so preview matches what will be inserted)
      let suggestionText = newCompletion
      if (storage.position !== undefined) {
        const textBefore = editor.state.doc.textBetween(Math.max(0, storage.position - 1), storage.position)
        if (textBefore && !/\s/.test(textBefore) && !suggestionText.startsWith(' ')) {
          suggestionText = ' ' + suggestionText
        }
      }
      storage.setSuggestion(suggestionText)
      editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))
    } else if (insertState.value) {
      // Direct insertion/transform mode (from toolbar actions)

      // Transform modes use markdown insertion - wait for full completion
      const transformModes = ['fix', 'extend', 'reduce', 'simplify', 'summarize', 'translate']
      if (transformModes.includes(mode.value)) {
        // Don't stream - will be handled in onFinish
        return
      }

      // If this is the first chunk and we have a selection to replace, delete it first
      if (insertState.value.deleteRange && !oldCompletion) {
        editor.chain()
          .focus()
          .deleteRange(insertState.value.deleteRange)
          .run()
        insertState.value.deleteRange = undefined
      }

      let delta = newCompletion.slice(oldCompletion?.length || 0)
      if (delta) {
        // For single-paragraph transforms, replace all line breaks with spaces
        if (['fix', 'simplify', 'translate'].includes(mode.value)) {
          delta = delta.replace(/[\r\n]+/g, ' ').replace(/\s{2,}/g, ' ')
        }

        // For "continue" mode, add a space before if needed (first chunk only)
        if (mode.value === 'continue' && !oldCompletion) {
          const textBefore = editor.state.doc.textBetween(Math.max(0, insertState.value.pos - 1), insertState.value.pos)
          if (textBefore && !/\s/.test(textBefore)) {
            delta = ' ' + delta
          }
        }

        editor.chain().focus().command(({ tr }) => {
          tr.insertText(delta, insertState.value!.pos)
          return true
        }).run()
        insertState.value.pos += delta.length
      }
    }
  })

  function triggerTransform(editor: Editor, transformMode: Exclude<CompletionMode, 'continue'>, lang?: string) {
    if (isLoading.value) return

    getCompletionStorage()?.clearSuggestion()

    const { state } = editor
    const { selection } = state

    if (selection.empty) return

    mode.value = transformMode
    language.value = lang
    const selectedText = state.doc.textBetween(selection.from, selection.to)

    // Replace the selected text with the transformed version
    insertState.value = { pos: selection.from, deleteRange: { from: selection.from, to: selection.to } }

    complete(selectedText)
  }

  function getMarkdownBefore(editor: Editor, pos: number): string {
    const { state } = editor
    const serializer = (editor.storage.markdown as { serializer?: { serialize: (content: unknown) => string } })?.serializer
    if (serializer) {
      const slice = state.doc.slice(0, pos)
      return serializer.serialize(slice.content)
    }
    // Fallback to plain text
    return state.doc.textBetween(0, pos, '\n')
  }

  function triggerContinue(editor: Editor) {
    if (isLoading.value) return

    mode.value = 'continue'
    getCompletionStorage()?.clearSuggestion()
    const { state } = editor
    const { selection } = state

    if (selection.empty) {
      // No selection: continue from cursor position
      const textBefore = getMarkdownBefore(editor, selection.from)
      insertState.value = { pos: selection.from }
      complete(textBefore)
    } else {
      // Text selected: append completion after the selection
      const textBefore = getMarkdownBefore(editor, selection.to)
      insertState.value = { pos: selection.to }
      complete(textBefore)
    }
  }

  // Configure Completion extension
  const extension = Completion.configure({
    onTrigger: (editor) => {
      if (isLoading.value) return
      mode.value = 'continue'
      const textBefore = getMarkdownBefore(editor, editor.state.selection.from)
      complete(textBefore)
    },
    onAccept: () => {
      setCompletion('')
    },
    onDismiss: () => {
      stop()
      setCompletion('')
    }
  })

  // Create handlers for toolbar
  const handlers = {
    aiContinue: {
      canExecute: () => !isLoading.value,
      execute: (editor: Editor) => {
        triggerContinue(editor)
        return editor.chain()
      },
      isActive: () => !!(isLoading.value && mode.value === 'continue'),
      isDisabled: () => !!isLoading.value
    },
    aiFix: {
      canExecute: (editor: Editor) => !editor.state.selection.empty && !isLoading.value,
      execute: (editor: Editor) => {
        triggerTransform(editor, 'fix')
        return editor.chain()
      },
      isActive: () => !!(isLoading.value && mode.value === 'fix'),
      isDisabled: (editor: Editor) => editor.state.selection.empty || !!isLoading.value
    },
    aiExtend: {
      canExecute: (editor: Editor) => !editor.state.selection.empty && !isLoading.value,
      execute: (editor: Editor) => {
        triggerTransform(editor, 'extend')
        return editor.chain()
      },
      isActive: () => !!(isLoading.value && mode.value === 'extend'),
      isDisabled: (editor: Editor) => editor.state.selection.empty || !!isLoading.value
    },
    aiReduce: {
      canExecute: (editor: Editor) => !editor.state.selection.empty && !isLoading.value,
      execute: (editor: Editor) => {
        triggerTransform(editor, 'reduce')
        return editor.chain()
      },
      isActive: () => !!(isLoading.value && mode.value === 'reduce'),
      isDisabled: (editor: Editor) => editor.state.selection.empty || !!isLoading.value
    },
    aiSimplify: {
      canExecute: (editor: Editor) => !editor.state.selection.empty && !isLoading.value,
      execute: (editor: Editor) => {
        triggerTransform(editor, 'simplify')
        return editor.chain()
      },
      isActive: () => !!(isLoading.value && mode.value === 'simplify'),
      isDisabled: (editor: Editor) => editor.state.selection.empty || !!isLoading.value
    },
    aiSummarize: {
      canExecute: (editor: Editor) => !editor.state.selection.empty && !isLoading.value,
      execute: (editor: Editor) => {
        triggerTransform(editor, 'summarize')
        return editor.chain()
      },
      isActive: () => !!(isLoading.value && mode.value === 'summarize'),
      isDisabled: (editor: Editor) => editor.state.selection.empty || !!isLoading.value
    },
    aiTranslate: {
      canExecute: (editor: Editor) => !editor.state.selection.empty && !isLoading.value,
      execute: (editor: Editor, cmd: { language?: string } | undefined) => {
        triggerTransform(editor, 'translate', cmd?.language)
        return editor.chain()
      },
      isActive: (_editor: Editor, cmd: { language?: string } | undefined) => !!(isLoading.value && mode.value === 'translate' && language.value === cmd?.language),
      isDisabled: (editor: Editor) => editor.state.selection.empty || !!isLoading.value
    }
  }

  return {
    extension,
    handlers,
    isLoading,
    mode
  }
}
  1. Crear un punto final de la API del servidor para manejar solicitudes de finalización utilizando streamText:
server/api/completion.post.ts
import { streamText, createTextStreamResponse } from 'ai'
import { gateway } from '@ai-sdk/gateway'

export default defineEventHandler(async (event) => {
  const { prompt, mode, language } = await readBody(event)
  if (!prompt) {
    throw createError({ statusCode: 400, message: 'Prompt is required' })
  }

  let instructions: string
  let maxOutputTokens: number

  const preserveMarkdown = 'IMPORTANT: Preserve all markdown formatting (bold, italic, links, etc.) exactly as in the original.'

  switch (mode) {
    case 'fix':
      instructions = `You are a writing assistant. Fix all spelling and grammar errors in the given text. ${preserveMarkdown} Only output the corrected text, nothing else.`
      maxOutputTokens = 500
      break
    case 'extend':
      instructions = `You are a writing assistant. Extend the given text with more details, examples, and explanations while maintaining the same style. ${preserveMarkdown} Only output the extended text, nothing else.`
      maxOutputTokens = 500
      break
    case 'reduce':
      instructions = `You are a writing assistant. Make the given text more concise by removing unnecessary words while keeping the meaning. ${preserveMarkdown} Only output the reduced text, nothing else.`
      maxOutputTokens = 300
      break
    case 'simplify':
      instructions = `You are a writing assistant. Simplify the given text to make it easier to understand, using simpler words and shorter sentences. ${preserveMarkdown} Only output the simplified text, nothing else.`
      maxOutputTokens = 400
      break
    case 'summarize':
      instructions = 'You are a writing assistant. Summarize the given text concisely while keeping the key points. Only output the summary, nothing else.'
      maxOutputTokens = 200
      break
    case 'translate':
      instructions = `You are a writing assistant. Translate the given text to ${language || 'English'}. ${preserveMarkdown} Only output the translated text, nothing else.`
      maxOutputTokens = 500
      break
    case 'continue':
    default:
      instructions = `You are a writing assistant providing inline autocompletions.
CRITICAL RULES:
- Output ONLY the NEW text that comes AFTER the user's input
- NEVER repeat any words from the end of the user's text
- Keep completions short (1 sentence max)
- Match the tone and style of the existing text
- ${preserveMarkdown}`
      maxOutputTokens = 25
      break
  }

  const result = streamText({
    model: gateway('anthropic/claude-haiku-4.5'),
    instructions,
    prompt,
    maxOutputTokens
  })

  return createTextStreamResponse({ stream: result.textStream })
})
  1. Use el composable en el Editor:
<script setup lang="ts">
import type { EditorCustomHandlers, EditorToolbarItem } from '@nuxt/ui'
import { useEditorCompletion } from './EditorUseCompletion'

const editorRef = useTemplateRef('editorRef')

const value = ref(`# AI Completion

This editor demonstrates how to add AI-powered features using the [Vercel AI SDK](https://ai-sdk.dev/). It includes ghost text autocompletion that appears as you type (press Tab to accept) and text transformation actions.

Try selecting some text and using the AI dropdown to fix grammar, extend, or simplify it.`)

const {
  extension: completionExtension,
  handlers: aiHandlers,
  isLoading: aiLoading
} = useEditorCompletion(editorRef)

const customHandlers = {
  ...aiHandlers
} satisfies EditorCustomHandlers

const items = computed(
  () =>
    [
      [
        {
          icon: 'i-lucide-sparkles',
          label: 'Improve',
          variant: 'soft',
          loading: aiLoading.value,
          content: {
            align: 'start'
          },
          items: [
            {
              kind: 'aiFix',
              icon: 'i-lucide-spell-check',
              label: 'Fix spelling & grammar'
            },
            {
              kind: 'aiExtend',
              icon: 'i-lucide-unfold-vertical',
              label: 'Extend text'
            },
            {
              kind: 'aiReduce',
              icon: 'i-lucide-fold-vertical',
              label: 'Reduce text'
            },
            {
              kind: 'aiSimplify',
              icon: 'i-lucide-lightbulb',
              label: 'Simplify text'
            },
            {
              kind: 'aiContinue',
              icon: 'i-lucide-text',
              label: 'Continue sentence'
            },
            {
              kind: 'aiSummarize',
              icon: 'i-lucide-list',
              label: 'Summarize'
            },
            {
              icon: 'i-lucide-languages',
              label: 'Translate',
              children: [
                {
                  kind: 'aiTranslate',
                  language: 'English',
                  label: 'English'
                },
                {
                  kind: 'aiTranslate',
                  language: 'French',
                  label: 'French'
                },
                {
                  kind: 'aiTranslate',
                  language: 'Spanish',
                  label: 'Spanish'
                },
                {
                  kind: 'aiTranslate',
                  language: 'German',
                  label: 'German'
                }
              ]
            }
          ]
        }
      ],
      [
        {
          icon: 'i-lucide-heading',
          content: {
            align: 'start'
          },
          items: [
            {
              kind: 'heading',
              level: 1,
              icon: 'i-lucide-heading-1',
              label: 'Heading 1'
            },
            {
              kind: 'heading',
              level: 2,
              icon: 'i-lucide-heading-2',
              label: 'Heading 2'
            },
            {
              kind: 'heading',
              level: 3,
              icon: 'i-lucide-heading-3',
              label: 'Heading 3'
            }
          ]
        }
      ],
      [
        {
          kind: 'mark',
          mark: 'bold',
          icon: 'i-lucide-bold'
        },
        {
          kind: 'mark',
          mark: 'italic',
          icon: 'i-lucide-italic'
        },
        {
          kind: 'mark',
          mark: 'underline',
          icon: 'i-lucide-underline'
        }
      ]
    ] satisfies EditorToolbarItem<typeof customHandlers>[][]
)
</script>

<template>
  <UEditor
    ref="editorRef"
    v-slot="{ editor }"
    v-model="value"
    :extensions="[completionExtension]"
    :handlers="customHandlers"
    content-type="markdown"
    :ui="{ base: 'p-8 sm:px-16' }"
    class="w-full min-h-74"
  >
    <UEditorToolbar
      :editor="editor"
      :items="items"
      class="border-b border-muted py-2 px-8 sm:px-16 overflow-x-auto"
    />
  </UEditor>
</template>
<script setup lang="ts">
import { ref, computed, useTemplateRef } from 'vue'
import type { EditorCustomHandlers, EditorToolbarItem } from '@nuxt/ui'
import { useEditorCompletion } from './EditorUseCompletion'

const editorRef = useTemplateRef('editorRef')

const value = ref(`# AI Completion

This editor demonstrates how to add AI-powered features using the [Vercel AI SDK](https://ai-sdk.dev/). It includes ghost text autocompletion that appears as you type (press Tab to accept) and text transformation actions.

Try selecting some text and using the AI dropdown to fix grammar, extend, or simplify it.`)

const {
  extension: completionExtension,
  handlers: aiHandlers,
  isLoading: aiLoading
} = useEditorCompletion(editorRef)

const customHandlers = {
  ...aiHandlers
} satisfies EditorCustomHandlers

const items = computed(
  () =>
    [
      [
        {
          icon: 'i-lucide-sparkles',
          label: 'Improve',
          variant: 'soft',
          loading: aiLoading.value,
          content: {
            align: 'start'
          },
          items: [
            {
              kind: 'aiFix',
              icon: 'i-lucide-spell-check',
              label: 'Fix spelling & grammar'
            },
            {
              kind: 'aiExtend',
              icon: 'i-lucide-unfold-vertical',
              label: 'Extend text'
            },
            {
              kind: 'aiReduce',
              icon: 'i-lucide-fold-vertical',
              label: 'Reduce text'
            },
            {
              kind: 'aiSimplify',
              icon: 'i-lucide-lightbulb',
              label: 'Simplify text'
            },
            {
              kind: 'aiContinue',
              icon: 'i-lucide-text',
              label: 'Continue sentence'
            },
            {
              kind: 'aiSummarize',
              icon: 'i-lucide-list',
              label: 'Summarize'
            },
            {
              icon: 'i-lucide-languages',
              label: 'Translate',
              children: [
                {
                  kind: 'aiTranslate',
                  language: 'English',
                  label: 'English'
                },
                {
                  kind: 'aiTranslate',
                  language: 'French',
                  label: 'French'
                },
                {
                  kind: 'aiTranslate',
                  language: 'Spanish',
                  label: 'Spanish'
                },
                {
                  kind: 'aiTranslate',
                  language: 'German',
                  label: 'German'
                }
              ]
            }
          ]
        }
      ],
      [
        {
          icon: 'i-lucide-heading',
          content: {
            align: 'start'
          },
          items: [
            {
              kind: 'heading',
              level: 1,
              icon: 'i-lucide-heading-1',
              label: 'Heading 1'
            },
            {
              kind: 'heading',
              level: 2,
              icon: 'i-lucide-heading-2',
              label: 'Heading 2'
            },
            {
              kind: 'heading',
              level: 3,
              icon: 'i-lucide-heading-3',
              label: 'Heading 3'
            }
          ]
        }
      ],
      [
        {
          kind: 'mark',
          mark: 'bold',
          icon: 'i-lucide-bold'
        },
        {
          kind: 'mark',
          mark: 'italic',
          icon: 'i-lucide-italic'
        },
        {
          kind: 'mark',
          mark: 'underline',
          icon: 'i-lucide-underline'
        }
      ]
    ] satisfies EditorToolbarItem<typeof customHandlers>[][]
)
</script>

<template>
  <UEditor
    ref="editorRef"
    v-slot="{ editor }"
    v-model="value"
    :extensions="[completionExtension]"
    :handlers="customHandlers"
    content-type="markdown"
    :ui="{ base: 'p-8 sm:px-16' }"
    class="w-full min-h-74"
  >
    <UEditorToolbar
      :editor="editor"
      :items="items"
      class="border-b border-muted py-2 px-8 sm:px-16 overflow-x-auto"
    />
  </UEditor>
</template>
La extensión de finalización se puede configurar con autoTrigger: true para sugerir automáticamente terminaciones mientras se escribe (deshabilitada de forma predeterminada). También puede activarla manualmente con: kbd{value="meta"}: kbd{value="j" class="ms-px"}.
Obtenga más información sobre Vercel AI SDK y los proveedores disponibles.

API

Accesorios

Prop Default Type
as
'div'
any

The element or component this component should render as.

modelValue
null | string | JSONContent | JSONContent[]
contentType
"markdown" | "json" | "html"

The content type the content is provided as. When not specified, it's automatically inferred: strings are treated as 'html', objects as 'json'.

starterKit
true
boolean | Partial<StarterKitOptions>

The starter kit options to configure the editor. Set to false for a plain-text editor: keeps the essential nodes (paragraph, text, history) and disables all rich-text formatting.

placeholder
{ showOnlyWhenEditable: false, showOnlyCurrent: true, mode: 'everyLine' }
string | Partial<PlaceholderOptions> & { mode?: "firstLine" | "everyLine" | undefined; }

The placeholder text to show in empty paragraphs. Can be a string or PlaceholderOptions from @tiptap/extension-placeholder.

markdown
{ markedOptions: { gfm: true } }
Partial<MarkdownExtensionOptions>

The markdown extension options to configure markdown parsing and serialization.

image
true
boolean | Partial<ImageOptions>

The image extension options to configure image handling. Set to false to disable the extension.

mention
true
boolean | Partial<Omit<MentionOptions<any, MentionNodeAttrs>, "suggestion" | "suggestions">>

The mention extension options to configure mention handling. Set to false to disable the extension. The suggestion and suggestions options are omitted as they are managed by the EditorMentionMenu component.

handlers
H

Custom item handlers to override or extend the default handlers. These handlers are provided to all child components (toolbar, suggestion menu, etc.).

extensions
Extensions

The extensions to use

injectCSS
boolean

Whether to inject base CSS styles

injectNonce
string

A nonce to use for CSP while injecting styles

autofocus
null | number | false | true | "start" | "end" | "all"

The editor's initial focus position

editable
boolean

Whether the editor is editable

textDirection
"ltr" | "rtl" | "auto"

The default text direction for all content in the editor. When set to 'ltr' or 'rtl', all nodes will have the corresponding dir attribute. When set to 'auto', the dir attribute will be set based on content detection. When undefined, no dir attribute will be added.

editorProps
EditorProps<any>

The editor's props

parseOptions
ParseOptions
coreExtensionOptions
{ clipboardTextSerializer?: { blockSeparator?: string | undefined; } | undefined; tabindex?: { value?: string | undefined; } | undefined; delete?: { async?: boolean | undefined; filterTransaction?: ((transaction: Transaction) => boolean) | undefined; } | undefined; }

The editor's core extension options

enableInputRules
false | true | (string | AnyExtension)[]

Whether to enable input rules behavior

enablePasteRules
false | true | (string | AnyExtension)[]

Whether to enable paste rules behavior

enableCoreExtensions
boolean | Partial<Record<"editable" | "textDirection" | "clipboardTextSerializer" | "commands" | "focusEvents" | "keymap" | "tabindex" | "drop" | "paste" | "delete", false>>

Determines whether core extensions are enabled.

If set to false, all core extensions will be disabled. To disable specific core extensions, provide an object where the keys are the extension names and the values are false. Extensions not listed in the object will remain enabled.

enableContentCheck
boolean

If true, the editor will check the content for errors on initialization. Emitting the contentError event if the content is invalid. Which can be used to show a warning or error message to the user.

emitContentError
boolean

If true, the editor will emit the contentError event if invalid content is encountered but enableContentCheck is false. This lets you preserve the invalid editor content while still showing a warning or error message to the user.

onBeforeCreate
(props: { editor: Editor; }): void

Called before the editor is constructed.

onCreate
(props: { editor: Editor; }): void

Called after the editor is constructed.

onMount
(props: { editor: Editor; }): void

Called when the editor is mounted.

onUnmount
(props: { editor: Editor; }): void

Called when the editor is unmounted.

onContentError
(props: { editor: Editor; error: Error; disableCollaboration: () => void; }): void

Called when the editor encounters an error while parsing the content. Only enabled if enableContentCheck is true.

onUpdate
(props: { editor: Editor; transaction: Transaction; appendedTransactions: Transaction[]; }): void

Called when the editor's content is updated.

onSelectionUpdate
(props: { editor: Editor; transaction: Transaction; }): void

Called when the editor's selection is updated.

onTransaction
(props: { editor: Editor; transaction: Transaction; appendedTransactions: Transaction[]; }): void

Called after a transaction is applied to the editor.

onFocus
(props: { editor: Editor; event: FocusEvent; transaction: Transaction; }): void

Called on focus events.

onBlur
(props: { editor: Editor; event: FocusEvent; transaction: Transaction; }): void

Called on blur events.

onDestroy
(props: void): void

Called when the editor is destroyed.

onPaste
(e: ClipboardEvent, slice: Slice): void

Called when content is pasted into the editor.

onDrop
(e: DragEvent, slice: Slice, moved: boolean): void

Called when content is dropped into the editor.

onDelete
(props: { editor: Editor; deletedRange: Range; newRange: Range; transaction: Transaction; combinedTransform: Transform; partial: boolean; from: number; to: number; } & ({ ...; } | { ...; })): void

Called when content is deleted from the editor.

enableExtensionDispatchTransaction
boolean

Whether to enable extension-level dispatching of transactions. If false, extensions cannot define their own dispatchTransaction hook.

ui
{ root?: SlotClass; content?: SlotClass; base?: SlotClass; }

Slots

Slot Type
default
{ editor: Editor; handlers: EditorHandlers<H>; }

Emisiones

Event Type
update:modelValue
[value: T]

Exposición

Al acceder al componente a través de una referencia de plantilla, puede utilizar lo siguiente:

NombreTipo
editorx{lang="ts-type"} (Edición española)Ref<Editor | undefined>x{lang="ts-type"} (Edición española)
La instancia del editor expuesta es la API del editor de TipTap. Compruebe la documentación de TipTap para ver todos los métodos y propiedades disponibles.

Temas

app.config.ts
export default defineAppConfig({
  ui: {
    editor: {
      slots: {
        root: '',
        content: 'relative size-full flex-1',
        base: [
          'w-full outline-none *:my-5 *:first:mt-0 *:last:mb-0 sm:px-8 selection:bg-primary/20',
          '[&_p]:leading-7',
          '[&_a]:text-primary [&_a]:border-b [&_a]:border-transparent [&_a]:hover:border-primary [&_a]:font-medium',
          '[&_a]:transition-colors',
          '[&_a>code]:border-dashed [&_a:hover>code]:border-primary [&_a:hover>code]:text-primary',
          '[&_a>code]:transition-colors',
          '[&_.mention]:text-primary [&_.mention]:font-medium',
          '[&_:is(h1,h2,h3,h4,h5,h6)]:text-highlighted [&_:is(h1,h2,h3,h4,h5,h6)]:font-bold',
          '[&_h1]:text-3xl',
          '[&_h2]:text-2xl',
          '[&_h3]:text-xl',
          '[&_h4]:text-lg',
          '[&_h5]:text-base',
          '[&_h6]:text-base',
          '[&_:is(h1,h2,h3,h4,h5,h6)>code]:border-dashed [&_:is(h1,h2,h3,h4,h5,h6)>code]:font-bold',
          '[&_h2>code]:text-xl/6',
          '[&_h3>code]:text-lg/5',
          '[&_blockquote]:border-s-4 [&_blockquote]:border-accented [&_blockquote]:ps-4 [&_blockquote]:italic',
          '[&_[data-type=horizontalRule]]:my-8 [&_[data-type=horizontalRule]]:py-2',
          '[&_hr]:border-t [&_hr]:border-default',
          '[&_pre]:text-sm/6 [&_pre]:border [&_pre]:border-muted [&_pre]:bg-muted [&_pre]:rounded-md [&_pre]:px-4 [&_pre]:py-3 [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:overflow-x-auto',
          '[&_pre_code]:p-0 [&_pre_code]:text-inherit [&_pre_code]:font-inherit [&_pre_code]:rounded-none [&_pre_code]:inline [&_pre_code]:border-0 [&_pre_code]:bg-transparent',
          '[&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-sm [&_code]:font-mono [&_code]:font-medium [&_code]:rounded-md [&_code]:inline-block [&_code]:border [&_code]:border-muted [&_code]:text-highlighted [&_code]:bg-muted',
          '[&_:is(ul,ol)]:ps-6',
          '[&_ul]:list-disc [&_ul]:marker:text-(--ui-border-accented)',
          '[&_ol]:list-decimal [&_ol]:marker:text-muted',
          '[&_li]:my-1.5 [&_li]:ps-1.5',
          '[&_img]:rounded-md [&_img]:block [&_img]:max-w-full [&_img.ProseMirror-selectednode]:outline-2 [&_img.ProseMirror-selectednode]:outline-primary',
          '[&_.ProseMirror-selectednode:not(img):not(pre):not([data-node-view-wrapper])]:bg-primary/20'
        ]
      },
      variants: {
        placeholderMode: {
          firstLine: {
            base: '[&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:content-[attr(data-placeholder)] [&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:text-dimmed [&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:float-start [&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:h-0 [&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:pointer-events-none'
          },
          everyLine: {
            base: '[&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:content-[attr(data-placeholder)] [&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:text-dimmed [&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:float-start [&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:h-0 [&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:pointer-events-none'
          }
        }
      },
      defaultVariants: {
        placeholderMode: 'everyLine'
      }
    }
  }
})
vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from '@nuxt/ui/vite'

export default defineConfig({
  plugins: [
    vue(),
    ui({
      ui: {
        editor: {
          slots: {
            root: '',
            content: 'relative size-full flex-1',
            base: [
              'w-full outline-none *:my-5 *:first:mt-0 *:last:mb-0 sm:px-8 selection:bg-primary/20',
              '[&_p]:leading-7',
              '[&_a]:text-primary [&_a]:border-b [&_a]:border-transparent [&_a]:hover:border-primary [&_a]:font-medium',
              '[&_a]:transition-colors',
              '[&_a>code]:border-dashed [&_a:hover>code]:border-primary [&_a:hover>code]:text-primary',
              '[&_a>code]:transition-colors',
              '[&_.mention]:text-primary [&_.mention]:font-medium',
              '[&_:is(h1,h2,h3,h4,h5,h6)]:text-highlighted [&_:is(h1,h2,h3,h4,h5,h6)]:font-bold',
              '[&_h1]:text-3xl',
              '[&_h2]:text-2xl',
              '[&_h3]:text-xl',
              '[&_h4]:text-lg',
              '[&_h5]:text-base',
              '[&_h6]:text-base',
              '[&_:is(h1,h2,h3,h4,h5,h6)>code]:border-dashed [&_:is(h1,h2,h3,h4,h5,h6)>code]:font-bold',
              '[&_h2>code]:text-xl/6',
              '[&_h3>code]:text-lg/5',
              '[&_blockquote]:border-s-4 [&_blockquote]:border-accented [&_blockquote]:ps-4 [&_blockquote]:italic',
              '[&_[data-type=horizontalRule]]:my-8 [&_[data-type=horizontalRule]]:py-2',
              '[&_hr]:border-t [&_hr]:border-default',
              '[&_pre]:text-sm/6 [&_pre]:border [&_pre]:border-muted [&_pre]:bg-muted [&_pre]:rounded-md [&_pre]:px-4 [&_pre]:py-3 [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:overflow-x-auto',
              '[&_pre_code]:p-0 [&_pre_code]:text-inherit [&_pre_code]:font-inherit [&_pre_code]:rounded-none [&_pre_code]:inline [&_pre_code]:border-0 [&_pre_code]:bg-transparent',
              '[&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-sm [&_code]:font-mono [&_code]:font-medium [&_code]:rounded-md [&_code]:inline-block [&_code]:border [&_code]:border-muted [&_code]:text-highlighted [&_code]:bg-muted',
              '[&_:is(ul,ol)]:ps-6',
              '[&_ul]:list-disc [&_ul]:marker:text-(--ui-border-accented)',
              '[&_ol]:list-decimal [&_ol]:marker:text-muted',
              '[&_li]:my-1.5 [&_li]:ps-1.5',
              '[&_img]:rounded-md [&_img]:block [&_img]:max-w-full [&_img.ProseMirror-selectednode]:outline-2 [&_img.ProseMirror-selectednode]:outline-primary',
              '[&_.ProseMirror-selectednode:not(img):not(pre):not([data-node-view-wrapper])]:bg-primary/20'
            ]
          },
          variants: {
            placeholderMode: {
              firstLine: {
                base: '[&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:content-[attr(data-placeholder)] [&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:text-dimmed [&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:float-start [&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:h-0 [&_:is(p,h1,h2,h3,h4,h5,h6).is-editor-empty:first-child]:before:pointer-events-none'
              },
              everyLine: {
                base: '[&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:content-[attr(data-placeholder)] [&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:text-dimmed [&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:float-start [&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:h-0 [&_:is(p,h1,h2,h3,h4,h5,h6).is-empty]:before:pointer-events-none'
              }
            }
          },
          defaultVariants: {
            placeholderMode: 'everyLine'
          }
        }
      }
    })
  ]
})

Changelog (Edición española)

No recent changes