Usage
The Editor component provides a powerful rich text editing experience built on TipTap. It supports multiple content formats (JSON, HTML, Markdown), customizable toolbars, drag-and-drop block reordering, slash commands, mentions, emoji picker, and extensible architecture for adding custom functionality.
Content
Use the v-model directive to control the value of the 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>
<B24Editor v-model="value" class="w-full min-h-21" />
</template>
Content Type
The Editor automatically detects the content format based on v-model type: strings are treated as html and objects as json.
You can explicitly set the format using the content-type prop: json, html, or 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>
<B24Editor v-model="value" content-type="html" class="w-full min-h-21" />
</template>
Extensions
The Editor includes the following extensions by default:
- StarterKit - Core editing features (bold, italic, headings, lists, etc.)
- Placeholder - Show placeholder text (when placeholder prop is provided)
- Image - Insert and display images
- Mention - Add @ mentions support
- Markdown - Parse and serialize markdown (when content type is markdown)
starter-kit, placeholder, image, mention, markdown) to customize its behavior with TipTap options.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>
<B24Editor
v-model="value"
:extensions="[
Emoji,
TextAlign.configure({
types: ['heading', 'paragraph']
})
]"
/>
</template>
Placeholder
Use the placeholder prop to set a placeholder text that shows in empty paragraphs.
<script setup lang="ts">
const value = ref('<h1>Hello World</h1>\n<p></p>\n')
</script>
<template>
<B24Editor v-model="value" placeholder="Start writing..." class="w-full min-h-21" />
</template>
Starter Kit
Use the starter-kit prop to configure the built-in TipTap StarterKit extension which includes common editor features like bold, italic, headings, lists, blockquotes, code blocks, and more.
<script setup lang="ts">
const value = ref('<h1>Hello World</h1>\n')
</script>
<template>
<B24Editor
v-model="value"
:starter-kit="{
blockquote: false,
headings: {
levels: [1, 2, 3, 4]
},
dropcursor: {
color: 'var(--ui-color-accent-main-primary)',
width: 2
},
link: {
openOnClick: false
}
}"
/>
</template>
Handlers
Handlers wrap TipTap's built-in commands to provide a unified interface for editor actions. When you add a kind property to a EditorToolbar or EditorSuggestionMenu item, the corresponding handler executes the TipTap command and manages its state (active, disabled, etc.).
Default handlers
The Editor component provides these default handlers, which you can reference in toolbar or suggestion menu items using the kind property:
Here's how to use default handlers in toolbar or suggestion menu items:
<script setup lang="ts">
import type { EditorToolbarItem } from '@bitrix24/b24ui-nuxt'
import BoldIcon from '@bitrix24/b24icons-vue/outline/BoldIcon'
import ItalicIcon from '@bitrix24/b24icons-vue/outline/ItalicIcon'
import AlignLeftIcon from '@bitrix24/b24icons-vue/outline/AlignLeftIcon'
import AlignCenterIcon from '@bitrix24/b24icons-vue/outline/AlignCenterIcon'
import BulletedListIcon from '@bitrix24/b24icons-vue/outline/BulletedListIcon'
import NumberedListIcon from '@bitrix24/b24icons-vue/outline/NumberedListIcon'
import QuoteIcon from '@bitrix24/b24icons-vue/outline/QuoteIcon'
import LinkIcon from '@bitrix24/b24icons-vue/outline/LinkIcon'
const value = ref('<h1>Hello World</h1>\n')
const items: EditorToolbarItem[] = [
{ kind: 'mark', mark: 'bold', icon: BoldIcon },
{ kind: 'mark', mark: 'italic', icon: ItalicIcon },
{ kind: 'heading', level: 1 },
{ kind: 'heading', level: 2 },
{ kind: 'textAlign', align: 'left', icon: AlignLeftIcon },
{ kind: 'textAlign', align: 'center', icon: AlignCenterIcon },
{ kind: 'bulletList', icon: BulletedListIcon },
{ kind: 'orderedList', icon: NumberedListIcon },
{ kind: 'blockquote', icon: QuoteIcon },
{ kind: 'link', icon: LinkIcon }
]
</script>
<template>
<B24Editor v-slot="{ editor }" v-model="value">
<B24EditorToolbar :editor="editor" :items="items" />
</B24Editor>
</template>
Custom handlers
Use the handlers prop to extend or override the default handlers. Custom handlers are merged with the default handlers, allowing you to add new actions or modify existing behavior.
Each handler implements the EditorHandler interface:
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
}
Here's an example of creating custom handlers:
<script setup lang="ts">
import type { Editor } from '@tiptap/vue-3'
import type { EditorCustomHandlers, EditorToolbarItem } from '@bitrix24/b24ui-nuxt'
import BoldIcon from '@bitrix24/b24icons-vue/outline/BoldIcon'
import TextColorIcon from '@bitrix24/b24icons-vue/editor/TextColorIcon'
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: BoldIcon },
// Custom handler
{ kind: 'highlight', icon: TextColorIcon }
] satisfies EditorToolbarItem<typeof customHandlers>[]
</script>
<template>
<B24Editor v-slot="{ editor }" v-model="value" :handlers="customHandlers">
<B24EditorToolbar :editor="editor" :items="items" />
</B24Editor>
</template>
Examples
With toolbar
You can use the EditorToolbar component to add a fixed, bubble, or floating toolbar to the Editor with common formatting actions.
With drag handle
You can use the EditorDragHandle component to add a draggable handle for reordering blocks.
With suggestion menu
You can use the EditorSuggestionMenu component to add slash commands for quick formatting and insertions.
With mention menu
You can use the EditorMentionMenu component to add @ mentions for tagging users or entities.
With emoji menu
You can use the EditorEmojiMenu component to add emoji picker support.
With image upload
This example demonstrates how to create an image upload feature using the extensions prop to register a custom TipTap node and the handlers prop to define how the toolbar button triggers the upload flow.
- Create a Vue component that uses the FileUpload component:
- Create a custom TipTap extension to register the node:
Adding different instances of a keyed plugin error when creating a custom extension, you may need to add prosemirror-state to the vite optimizeDeps include list in your nuxt.config.ts file.export default defineNuxtConfig({
vite: {
optimizeDeps: {
include: ['prosemirror-state']
}
}
})
- Use the custom extension in the Editor:
With AI completion
This example demonstrates how to add AI-powered features to the Editor using the Vercel AI SDK, specifically the useCompletion composable for streaming text completions,
combined with the DeepSeek Provider to access AI models through a centralized endpoint.
It includes ghost text autocompletion and text transformation actions (fix grammar, extend, reduce, simplify, translate, etc.).
pnpm add ai @ai-sdk/deepseek @ai-sdk/vue
yarn add ai @ai-sdk/deepseek @ai-sdk/vue
npm install ai @ai-sdk/deepseek @ai-sdk/vue
bun add ai @ai-sdk/deepseek @ai-sdk/vue
- Create a custom TipTap extension that handles inline ghost text suggestions:
- Create a composable that manages AI completion state and handlers:
- Create a server API endpoint to handle completion requests using
streamText:
import { streamText } from 'ai'
import { createDeepSeek } from '@ai-sdk/deepseek'
export default defineEventHandler(async (event) => {
const { prompt, mode, language } = await readBody(event)
if (!prompt) {
throw createError({ statusCode: 400, message: 'Prompt is required' })
}
let system: string
let maxOutputTokens: number
const deepseek = createDeepSeek({
apiKey: process.env.DEEPSEEK_API_KEY ?? ''
})
const preserveMarkdown = 'IMPORTANT: Preserve all markdown formatting (bold, italic, links, etc.) exactly as in the original.'
switch (mode) {
case 'fix':
system = `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':
system = `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':
system = `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':
system = `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':
system = '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':
system = `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:
system = `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
}
return streamText({
model: deepseek('deepseek-chat'), // or 'deepseek-reasoner'
system,
prompt,
maxOutputTokens
}).toTextStreamResponse()
})
- Use the composable in the Editor:
API
Props
Slots
Emits
Expose
When accessing the component via a template ref, you can use the following:
Theme
export default defineAppConfig({
b24ui: {
editor: {
slots: {
root: '',
content: 'relative size-full flex-1',
base: 'text-(--b24ui-typography-label-color) w-full outline-none *:my-5 *:first:mt-0 *:last:mb-0 sm:px-8 selection:bg-(--ui-color-design-selection-bg) [&_:is(p,h1,h2,h3,h4).is-empty]:before:content-[attr(data-placeholder)] [&_:is(p,h1,h2,h3,h4).is-empty]:before:text-(--b24ui-typography-description-color) [&_:is(p,h1,h2,h3,h4).is-empty]:before:float-left [&_:is(p,h1,h2,h3,h4).is-empty]:before:h-0 [&_:is(p,h1,h2,h3,h4).is-empty]:before:pointer-events-none [&_li_.is-empty]:before:content-none [&_p]:leading-7 [&_a]:text-(--ui-color-accent-main-primary) [&_a]:border-b [&_a]:border-transparent [&_a]:hover:border-(--ui-color-accent-main-primary) [&_a]:font-(--ui-font-weight-medium) [&_a]:transition-colors [&_.mention]:text-(--ui-color-accent-main-primary) [&_.mention]:font-(--ui-font-weight-medium) [&_:is(h1,h2,h3,h4)]:text-(--b24ui-typography-label-color) [&_:is(h1,h2,h3,h4)]:font-(--ui-font-weight-bold) [&_h1]:text-3xl [&_h2]:text-2xl [&_h3]:text-xl [&_h4]:text-lg [&_:is(h1,h2,h3,h4)>code]:border-dashed [&_:is(h1,h2,h3,h4)>code]:font-(--ui-font-weight-bold) [&_h2>code]:text-xl/6 [&_h3>code]:text-lg/5 [&_blockquote]:border-s-4 [&_blockquote]:border-(--ui-color-accent-soft-element-blue) [&_blockquote]:ps-4 [&_blockquote]:italic [&_[data-type=horizontalRule]]:my-8 [&_[data-type=horizontalRule]]:py-2 [&_hr]:border-t [&_hr]:border-(--ui-color-divider-default) [&_pre]:text-sm/6 [&_pre]:border [&_pre]:border-(--ui-color-design-tinted-na-stroke) [&_pre]:bg-(--ui-color-design-tinted-na-bg) [&_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-[family-name:var(--ui-font-family-system-mono)] [&_code]:font-(--ui-font-weight-medium) [&_code]:rounded-md [&_code]:inline-block [&_code]:border [&_code]:border-(--ui-color-design-tinted-na-stroke) [&_code]:text-(--b24ui-typography-label-color) [&_code]:bg-(--ui-color-design-tinted-na-bg) [&_:is(ul,ol)]:ps-6 [&_ul]:list-disc [&_ul]:marker:text-(--ui-color-accent-soft-element-blue) [&_ol]:list-decimal [&_ol]:marker:text-(--b24ui-typography-label-color) [&_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-(--ui-color-accent-main-primary) [&_.ProseMirror-selectednode:not(img):not(pre):not([data-node-view-wrapper])]:bg-(--ui-color-design-selection-bg)'
}
}
}
})
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import bitrix24UIPluginVite from '@bitrix24/b24ui-nuxt/vite'
export default defineConfig({
plugins: [
vue(),
bitrix24UIPluginVite({
b24ui: {
editor: {
slots: {
root: '',
content: 'relative size-full flex-1',
base: 'text-(--b24ui-typography-label-color) w-full outline-none *:my-5 *:first:mt-0 *:last:mb-0 sm:px-8 selection:bg-(--ui-color-design-selection-bg) [&_:is(p,h1,h2,h3,h4).is-empty]:before:content-[attr(data-placeholder)] [&_:is(p,h1,h2,h3,h4).is-empty]:before:text-(--b24ui-typography-description-color) [&_:is(p,h1,h2,h3,h4).is-empty]:before:float-left [&_:is(p,h1,h2,h3,h4).is-empty]:before:h-0 [&_:is(p,h1,h2,h3,h4).is-empty]:before:pointer-events-none [&_li_.is-empty]:before:content-none [&_p]:leading-7 [&_a]:text-(--ui-color-accent-main-primary) [&_a]:border-b [&_a]:border-transparent [&_a]:hover:border-(--ui-color-accent-main-primary) [&_a]:font-(--ui-font-weight-medium) [&_a]:transition-colors [&_.mention]:text-(--ui-color-accent-main-primary) [&_.mention]:font-(--ui-font-weight-medium) [&_:is(h1,h2,h3,h4)]:text-(--b24ui-typography-label-color) [&_:is(h1,h2,h3,h4)]:font-(--ui-font-weight-bold) [&_h1]:text-3xl [&_h2]:text-2xl [&_h3]:text-xl [&_h4]:text-lg [&_:is(h1,h2,h3,h4)>code]:border-dashed [&_:is(h1,h2,h3,h4)>code]:font-(--ui-font-weight-bold) [&_h2>code]:text-xl/6 [&_h3>code]:text-lg/5 [&_blockquote]:border-s-4 [&_blockquote]:border-(--ui-color-accent-soft-element-blue) [&_blockquote]:ps-4 [&_blockquote]:italic [&_[data-type=horizontalRule]]:my-8 [&_[data-type=horizontalRule]]:py-2 [&_hr]:border-t [&_hr]:border-(--ui-color-divider-default) [&_pre]:text-sm/6 [&_pre]:border [&_pre]:border-(--ui-color-design-tinted-na-stroke) [&_pre]:bg-(--ui-color-design-tinted-na-bg) [&_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-[family-name:var(--ui-font-family-system-mono)] [&_code]:font-(--ui-font-weight-medium) [&_code]:rounded-md [&_code]:inline-block [&_code]:border [&_code]:border-(--ui-color-design-tinted-na-stroke) [&_code]:text-(--b24ui-typography-label-color) [&_code]:bg-(--ui-color-design-tinted-na-bg) [&_:is(ul,ol)]:ps-6 [&_ul]:list-disc [&_ul]:marker:text-(--ui-color-accent-soft-element-blue) [&_ol]:list-decimal [&_ol]:marker:text-(--b24ui-typography-label-color) [&_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-(--ui-color-accent-main-primary) [&_.ProseMirror-selectednode:not(img):not(pre):not([data-node-view-wrapper])]:bg-(--ui-color-design-selection-bg)'
}
}
}
})
]
})