Utilisation
Le composant Éditeur offre une puissante expérience d'édition de texte enrichi construite sur TipTap. Il prend en charge plusieurs formats de contenu (JSON, HTML, Markdown), des barres d'outils personnalisables, glisser-déposer réorganisation de blocs, slash commandes, mentions, sélecteur d'emoji, et l'architecture extensible pour ajouter des fonctionnalités personnalisées.
Adding different instances of a keyed plugin lors de l'utilisation du composant Editor ou de ses extensions, vous devrez peut-être ajouter des paquets prosemirror à la liste vite.optimizeDeps.include dans votre fichier 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'
]
}
}
})
Contenu
Utilisez la directive v-model pour contrôler la valeur de l'Éditeur.
<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>Type de contenu
L'éditeur détecte automatiquement le format de contenu en fonction du type v-model: les chaînes sont traitées comme html et les objets comme json.
Vous pouvez définir explicitement le format à l'aide de la prop content-type: json, html ou xph074{lang="ts-type"}.
<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>Extensions
L'éditeur inclut les extensions suivantes par défaut:
- StarterKit-Fonctions d'édition de base (gras, italique, en-têtes, listes, etc.)
- Placeholder-Afficher le texte de l'espace réservé (lorsque la prop de l'espace réservé est fournie)
- Image-Insérer et afficher des images
- Mention-Add @ mentions support
- Markdown-Analyse et sérialisation du markdown (lorsque le type de contenu est markdown)
starter-kit, placeholder, image, mention, markdown) pour personnaliser son comportement avec les options TipTap.You can use the extensions prop to add additional TipTap extensions to enhance the Editor's capabilities:
<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>
Placeholder électronique
Utilisez la prop placeholder pour définir un texte réservé qui s'affiche dans les paragraphes vides.
<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>placeholder accepte une chaîne ou un objet avec PlaceholderOptions et une propriété mode supplémentaire:everyLine: Affiche l'espace réservé sur chaque ligne vide lorsque la mise au point (par défaut).firstLine: Affiche l'espace réservé uniquement sur la première ligne lorsque l'éditeur est vide.
<template>
<UEditor :placeholder="{ placeholder: 'Start writing...', mode: 'firstLine' }" />
</template>
includeChildren sur true:<template>
<UEditor :placeholder="{ placeholder: 'Start writing...', includeChildren: true }" />
</template>
Kit de démarrage
Utilisez le prop starter-kit pour configurer l'extension TipTap StarterKit intégrée qui inclut des fonctionnalités d'éditeur courantes telles que gras , italique , en-têtes , listes , citations de bloc , blocs de code , etc.
<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>
starter-kit sur false pour un éditeur de texte brut . Il conserve les nœuds essentiels (paragraphe , texte , historique) et désactive toutes les fonctionnalités de mise en forme telles que gras , italique , en-têtes , listes , code , blockquote , liens et règles horizontales .Handlers
Lorsque vous ajoutez une propriété kind à un élément EditorToolbar ou [EditorSuggestionMenuxph220/docs/components/editor-suggestion-menuxph2222x , le gestionnaire correspondant exécute la commande TipTap et gère son état (actif , désactivé , etc.) .
Default gestionnaires
Le composant Éditeur fournit ces gestionnaires par défaut , que vous pouvez référencer dans la barre d'outils ou les éléments de menu de suggestion à l'aide de la propriété kind :
| Handler était | Description | Utilisation |
|---|---|---|
markx{lang="ts-type"} | Basculer les marques de texte (gras , italique , grève , code , soulignement) | Nécessite la propriété mark dans l'item |
textAlignx{lang="ts-type"} | Définir l'alignement du texte (gauche , centre , droite , justifier) | Nécessite la propriété align dans l'item |
heading | Toggle niveaux de titre (1 - 6) | Nécessite la propriété level dans item |
linkx{lang="ts-type"} | Ajouter , éditer ou supprimer des liens | Indiquer l'URL si elle n'est pas fournie |
imagex{lang="ts-type"} | Insérer des images | Indiquer l'URL si elle n'est pas fournie |
blockquotex{lang="ts-type"} | Toggle blockquotes à | |
bulletList | Toggle bullet listes | Gestion des conversions liste |
orderedListx{lang="ts-type"} | Toggle listes ordonnées | Gestion des conversions liste |
taskListx{lang="ts-type"} | Toggle listes de tâches | Gestion des conversions liste |
codeBlockx{lang="ts-type"} | Toggle blocs de code | |
horizontalRulex{lang="ts-type"} | Insérer des règles horizontales | |
paragraphx{lang="ts-type"} | Format de paragraphe | |
undox{lang="ts-type"} | Undo dernier changement | |
redox{lang="ts-type"} | Redo dernier changement undone | |
clearFormatting | Supprimer tout formatage | Fonctionne avec sélection ou position |
duplicatex{lang="ts-type"} | Dupliquer le noeud | Nécessite la propriété pos dans l'item |
delete | Découvrez Node | Nécessite la propriété pos dans item |
moveUpx{lang="ts-type"} | Déplacer un node vers le haut | Nécessite la propriété pos dans l'item |
moveDownx{lang="ts-type"} | Déplacer un noeud vers le bas | Nécessite la propriété pos dans l'item |
suggestion | Menu de suggestion Trigger | Insérer le caractère / |
mentionx{lang="ts-type"} | Menu Trigger mentionné | Insérer le caractère @ |
emoji | Déclencheur Emoji Picker | Insérer le caractère : |
taskList et textAlign ne fonctionnent que lorsque leurs extensions respectives sont installées, car ils ne sont pas inclus dans l'Éditeur par défaut.Voici comment utiliser les gestionnaires par défaut dans la barre d'outils ou les éléments de menu de suggestion:
<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>
Gestionnaires personnalisés
Utilisez la prop handlers pour étendre ou remplacer les gestionnaires par défaut. Les gestionnaires personnalisés sont fusionnés avec les gestionnaires par défaut, vous pouvez donc ajouter de nouvelles actions ou modifier le comportement existant.
Chaque gestionnaire implémente l'interface 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
}
Voici un exemple de création de gestionnaires personnalisés:
<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>
Exemples
Avec barre d'outils
Vous pouvez utiliser le composant EditorToolbar pour ajouter une barre d'outils fixed, bubble ou floating à l'Éditeur avec des actions de mise en forme courantes.
<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>Avec poignée de traînée
Vous pouvez utiliser le composant EditorDragHandle pour ajouter une poignée glissable pour réorganiser les blocs.
<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>Avec menu de suggestions
Vous pouvez utiliser le composant EditorSuggestionMenu pour ajouter des commandes slash pour une mise en forme et des insertions rapides.
<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>Avec menu de mention
Vous pouvez utiliser le composant EditorMentionMenu pour ajouter des mentions @ pour marquer des utilisateurs ou des entités.
<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>Avec menu emoji
Vous pouvez utiliser le composant EditorEmojiMenu pour ajouter la prise en charge du sélecteur d'emoji.
<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>Avec téléchargement d'image
Cet exemple montre comment créer une fonctionnalité de téléchargement d'image à l'aide de la prop extensions pour enregistrer un nœud TipTap personnalisé et de la prop handlers pour définir comment le bouton de la barre d'outils déclenche le flux de téléchargement.
- Créer un composant Vue qui utilise le composant FileUpload:
<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>
<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>
- Créer une extension TipTap personnalisée pour enregistrer le nœud:
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
- Utilisez l'extension personnalisée dans l'éditeur:
<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>Avec complétion IA
Cet exemple montre comment ajouter des fonctionnalités alimentées par l'IA à l'éditeur à l'aide du SDK](https://ai-sdk.dev/) useCompletionxph45xxph45xxph45x) composable pour les complétions de texte en streaming, combiné avec la passerelle Vercel AI Gateway pour accéder aux modèles d'IA via un point de terminaison centralisé. Il comprend l'autocomplétion de texte fantôme et la transformation de texte des actions (corriger la grammaire, étendre, réduire, simplifier, traduire, etc.).
pnpm add ai @ai-sdk/gateway @ai-sdk/vue
yarn add ai @ai-sdk/gateway @ai-sdk/vue
npm install ai @ai-sdk/gateway @ai-sdk/vue
bun add ai @ai-sdk/gateway @ai-sdk/vue
- Créer une extension TipTap personnalisée qui gère les suggestions de texte fantôme en ligne:
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
- Créer un composable qui gère l'état d'achèvement de l'IA et les gestionnaires:
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
}
}
- Créer un point de terminaison API serveur pour gérer les demandes d'achèvement en utilisant xph489:
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 })
})
- Utilisez le composable dans l'éditeur:
<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>autoTrigger: true pour suggérer automatiquement des complétions lors de la saisie (désactivée par défaut). Vous pouvez également la déclencher manuellement avec: kbd{value="meta"}: kbd{value="j" class="ms-px"}.API
Props
| Prop | Default | Type |
|---|---|---|
as | 'div' | anyThe 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 |
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 |
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 |
mention | true | boolean | Partial<Omit<MentionOptions<any, MentionNodeAttrs>, "suggestion" | "suggestions">> The mention extension options to configure mention handling. Set to |
handlers | HCustom item handlers to override or extend the default handlers. These handlers are provided to all child components (toolbar, suggestion menu, etc.). | |
extensions | ExtensionsThe extensions to use | |
injectCSS | boolean Whether to inject base CSS styles | |
injectNonce | stringA 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 | |
enableContentCheck | boolean If | |
emitContentError | boolean If | |
onBeforeCreate | (props: { editor: Editor; }): voidCalled before the editor is constructed. | |
onCreate | (props: { editor: Editor; }): voidCalled after the editor is constructed. | |
onMount | (props: { editor: Editor; }): voidCalled when the editor is mounted. | |
onUnmount | (props: { editor: Editor; }): voidCalled when the editor is unmounted. | |
onContentError | (props: { editor: Editor; error: Error; disableCollaboration: () => void; }): voidCalled when the editor encounters an error while parsing the content.
Only enabled if | |
onUpdate | (props: { editor: Editor; transaction: Transaction; appendedTransactions: Transaction[]; }): voidCalled when the editor's content is updated. | |
onSelectionUpdate | (props: { editor: Editor; transaction: Transaction; }): voidCalled when the editor's selection is updated. | |
onTransaction | (props: { editor: Editor; transaction: Transaction; appendedTransactions: Transaction[]; }): voidCalled after a transaction is applied to the editor. | |
onFocus | (props: { editor: Editor; event: FocusEvent; transaction: Transaction; }): voidCalled on focus events. | |
onBlur | (props: { editor: Editor; event: FocusEvent; transaction: Transaction; }): voidCalled on blur events. | |
onDestroy | (props: void): voidCalled when the editor is destroyed. | |
onPaste | (e: ClipboardEvent, slice: Slice): voidCalled when content is pasted into the editor. | |
onDrop | (e: DragEvent, slice: Slice, moved: boolean): voidCalled 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; } & ({ ...; } | { ...; })): voidCalled when content is deleted from the editor. | |
enableExtensionDispatchTransaction | boolean Whether to enable extension-level dispatching of transactions.
If | |
ui | { root?: SlotClass; content?: SlotClass; base?: SlotClass; } |
Slots
| Slot | Type |
|---|---|
default | { editor: Editor; handlers: EditorHandlers<H>; } |
Emits
| Event | Type |
|---|---|
update:modelValue | [value: T] |
Exposer
Lorsque vous accédez au composant via une référence de modèle, vous pouvez utiliser les éléments suivants:
| nom | type |
|---|---|
editorx{lang="ts-type"} | Ref<Editor | undefined>x{lang="ts-type"} |
Thème
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'
}
}
}
})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'
}
}
}
})
]
})