Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions docs/content/docs/2.components/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,96 @@ const value = ref('<h1>Hello World</h1>\n')
Check out the image upload example for creating custom TipTap extensions.
::

### External editor

When you need full control over the editor β€” a custom schema, a different `StarterKit`, or a bespoke content lifecycle β€” you can create the TipTap editor yourself and pass it through the `editor` prop. The Editor then acts as a **shell**: it renders and styles the editor and wires it to the child components ([EditorToolbar](/docs/components/editor-toolbar), menus, …), but leaves the editor's extensions, content and lifecycle entirely to you.

```vue
<script setup lang="ts">
import { useEditor } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'

const editor = useEditor({
content: '<h1>Hello World</h1>',
extensions: [StarterKit]
})
</script>

<template>
<UEditor :editor="editor">
<UEditorToolbar :editor="editor" :items="items" />
</UEditor>
</template>
```

::note
In this mode, the engine props (`starter-kit`, `image`, `mention`, `placeholder`, `markdown`, `content-type`, `model-value`, `extensions`, `editor-props`, …) are **ignored** β€” the external editor owns its content, schema and options. Manage `v-model` on your own editor instance instead of on the `UEditor` component.
::

Comment thread
coderabbitai[bot] marked this conversation as resolved.
This is what enables editors backed by an extended starter kit, such as [comark-tiptap](https://github.com/sandros94/comark-tiptap), which provides a lossless markdown ↔ AST ↔ ProseMirror round-trip:

```vue
<script setup lang="ts">
import Mention from '@tiptap/extension-mention'
import { useComarkEditor } from 'comark-tiptap/vue'

const { editor } = useComarkEditor({
content: '# Hello World',
contentType: 'markdown',
// Your editor owns the schema, so register the extensions the feature
// components rely on here β€” e.g. `Mention` for `UEditorMentionMenu`.
extensions: [Mention]
})
</script>

<template>
<UEditor :editor="editor">
<UEditorToolbar :editor="editor" :items="items" />

<template #fallback>
Loading editor…
</template>
</UEditor>
</template>
```

The [EditorToolbar](/docs/components/editor-toolbar) items and handlers call your editor's commands directly, so only include items whose extensions are registered on your editor β€” same for the mention, emoji and suggestion menus (like `Mention` above). The theme's placeholder styling applies automatically once your editor registers the Placeholder extension.

::note
The editor instance is never modified: the theme's [prose classes](#prose) style your content from a wrapper element, so an external editor inherits the default typography automatically. Attributes of the editable element itself remain yours β€” for parity with the built-in editor, set the theme's `base` slot classes at creation:

```ts
const editor = useEditor({
// ...
editorProps: {
attributes: {
autocomplete: 'off',
autocorrect: 'off',
autocapitalize: 'off',
class: 'w-full outline-none *:my-5 *:first:mt-0 *:last:mb-0 sm:px-8 selection:bg-primary/20'
}
}
})
```

::

::tip
The `#fallback` slot renders while the editor is not yet available β€” during SSR and before mount, or while an asynchronously created external editor is still `undefined`.
::

### Prose

The editor content is styled with the theme's prose (typography) classes. Their targets are wrapped in `:where()` so each rule keeps the specificity of a single class: your own classes and any higher-specificity styling from extensions or stylesheets can always override them. Set the `prose` prop to `false` to disable them entirely, for example when the editor brings its own content styling:

```vue
<template>
<UEditor :editor="editor" :prose="false">
<UEditorToolbar :editor="editor" :items="items" />
</UEditor>
</template>
```

### Placeholder

Use the `placeholder` prop to set a placeholder text that shows in empty paragraphs.
Expand Down
185 changes: 124 additions & 61 deletions src/runtime/components/Editor.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import type { VNode } from 'vue'
import type { ComputedRef, MaybeRef, VNode } from 'vue'
import type { AppConfig } from '@nuxt/schema'
import type { Editor as TiptapEditor, EditorOptions, Content } from '@tiptap/vue-3'
import type { StarterKitOptions } from '@tiptap/starter-kit'
Expand All @@ -21,6 +21,16 @@ export interface EditorProps<T extends Content = Content, H extends EditorCustom
* @defaultValue 'div'
*/
as?: any
/**
* An external TipTap editor instance to use instead of the built-in one.
* The component renders and styles it and wires it to the child components (toolbar, menus, …),
* while the instance keeps ownership of its content, schema and lifecycle: `v-model` and the
* TipTap options props (`starterKit`, `extensions`, `editorProps`, …) are ignored.
* Bind the prop from the first render (even while still `undefined`, e.g. an editor created
* asynchronously) β€” it is detected by presence, not value.
* @see https://tiptap.dev/docs/editor/getting-started/install/vue3
*/
editor?: MaybeRef<TiptapEditor | undefined> | ComputedRef<TiptapEditor | undefined>
modelValue?: T
/**
* The content type the content is provided as.
Expand Down Expand Up @@ -66,6 +76,14 @@ export interface EditorProps<T extends Content = Content, H extends EditorCustom
* @see https://tiptap.dev/docs/editor/extensions/nodes/mention
*/
mention?: boolean | Partial<Omit<MentionOptions, 'suggestion' | 'suggestions'>>
/**
* Apply the theme's typography to the editor content wrapper, so external editors are styled too.
* Uses `:where()` selectors, so your own (or an extension's) styling always takes precedence.
* Set to `false` when the editor brings its own content styling.
* The built-in editor is always styled through the `base` slot (override `ui.base` to change it).
* @defaultValue true
*/
prose?: boolean
/**
* Custom item handlers to override or extend the default handlers.
* These handlers are provided to all child components (toolbar, suggestion menu, etc.).
Expand All @@ -80,12 +98,17 @@ export interface EditorEmits<T extends Content = Content> {
}

export interface EditorSlots<H extends EditorCustomHandlers = EditorCustomHandlers> {
default?(props: { editor: TiptapEditor, handlers: EditorHandlers<H> }): VNode[]
default?(props: { editor: TiptapEditor, handlers: EditorHandlers<H>, ui: Editor['ui'] }): VNode[]
/**
* Rendered while the editor is not yet available: during SSR and before mount,
* or while an asynchronously created external `editor` is still `undefined`.
*/
fallback?(props: Record<string, never>): VNode[]
}
</script>

<script setup lang="ts" generic="T extends Content, H extends EditorCustomHandlers">
import { computed, provide, useAttrs, watch } from 'vue'
import { computed, provide, unref, useAttrs, watch } from 'vue'
import { defu } from 'defu'
import { Primitive } from 'reka-ui'
import { mergeAttributes } from '@tiptap/core'
Expand All @@ -99,7 +122,7 @@ import StarterKit from '@tiptap/starter-kit'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import { reactiveOmit } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { useComponentProps } from '../composables/useComponentProps'
import { isPropBound, useComponentProps } from '../composables/useComponentProps'
import { useForwardProps } from '../composables/useForwardProps'
import { createHandlers } from '../utils/editor'
import { tv } from '../utils/tv'
Expand All @@ -120,12 +143,19 @@ const attrs = useAttrs()

const appConfig = useAppConfig() as Editor['AppConfig']

// External mode is detected from the presence of the `editor` prop, not its value,
// so an editor created asynchronously (`undefined` on first render) is still recognized.
const isExternalEditor = isPropBound('editor')

// eslint-disable-next-line vue/no-dupe-keys
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.editor || {}) })({
// Built-in editors are styled via `editorProps` on the editable element; only external editors
// need the content-wrapper copy, so the variant is disabled otherwise.
prose: isExternalEditor ? props.prose : false,
placeholderMode: typeof props.placeholder === 'object' ? props.placeholder.mode : undefined
}))

const rootProps = useForwardProps(reactiveOmit(props, 'starterKit', 'extensions', 'editorProps', 'contentType', 'class', 'placeholder', 'markdown', 'image', 'mention', 'handlers'))
const rootProps = useForwardProps(reactiveOmit(props, 'editor', 'starterKit', 'extensions', 'editorProps', 'contentType', 'class', 'placeholder', 'markdown', 'image', 'mention', 'prose', 'handlers'))

const editorProps = computed(() => defu(props.editorProps, {
attributes: {
Expand Down Expand Up @@ -206,68 +236,98 @@ const extensions = computed(() => [
...(props.extensions || [])
].filter(extension => !!extension))

const editor = useEditor({
...rootProps.value,
content: props.modelValue,
contentType: contentType.value,
extensions: extensions.value,
editorProps: editorProps.value,
onCreate: ({ editor }) => {
// Force placeholder decorations to render immediately without needing focus
if (props.placeholder) {
editor.view.dispatch(editor.state.tr)
const externalEditor = computed(() => unref(props.editor) as TiptapEditor | undefined)

if (import.meta.dev) {
if (isExternalEditor) {
// Every prop not listed here only configures the built-in editor.
const shellProps = ['editor', 'as', 'class', 'ui', 'handlers', 'prose']
const inertProps = Object.keys(_props).filter(name => !shellProps.includes(name) && isPropBound(name))
if (inertProps.length) {
console.warn(`[@nuxt/ui] <UEditor> ignores [${inertProps.join(', ')}] when the \`editor\` prop is provided; the external editor owns its content, schema and lifecycle.`)
}
},
onUpdate: ({ editor }) => {
let value
try {
if (contentType.value === 'html') {
value = editor.getHTML()
} else if (contentType.value === 'json') {
value = editor.getJSON()
} else if (contentType.value === 'markdown') {
value = editor.getMarkdown()
} else {
// The mode is fixed at setup, so an `editor` bound after the first render is inert.
const stop = watch(() => unref(props.editor), (value) => {
if (value) {
console.warn('[@nuxt/ui] <UEditor> received an `editor` after its first render and will keep using the built-in editor; bind the `editor` prop from the start (even while `undefined`) to use an external editor.')
stop()
}
})
}
}

// No `useEditor` in external mode: it creates its own instance and destroys it on unmount.
const internalEditor = isExternalEditor
? undefined
: useEditor({
...rootProps.value,
content: props.modelValue,
contentType: contentType.value,
extensions: extensions.value,
editorProps: editorProps.value,
onCreate: ({ editor }) => {
// Force placeholder decorations to render immediately without needing focus
if (props.placeholder) {
editor.view.dispatch(editor.state.tr)
}
},
onUpdate: ({ editor }) => {
let value
try {
if (contentType.value === 'html') {
value = editor.getHTML()
} else if (contentType.value === 'json') {
value = editor.getJSON()
} else if (contentType.value === 'markdown') {
value = editor.getMarkdown()
}
} catch (error) {
value = editor.getText()
}

emits('update:modelValue', value as T)
}
} catch (error) {
value = editor.getText()
})

// eslint-disable-next-line vue/no-dupe-keys
const editor = computed(() => isExternalEditor ? externalEditor.value : internalEditor?.value)

// `v-model` only syncs the built-in editor; an external editor owns its content.
if (!isExternalEditor) {
watch(() => props.modelValue, (newVal) => {
if (!editor.value || newVal == null) {
return
}

emits('update:modelValue', value as T)
}
})
const currentContent = contentType.value === 'html'
? editor.value.getHTML()
: contentType.value === 'json'
? JSON.stringify(editor.value.getJSON())
: contentType.value === 'markdown'
? editor.value.getMarkdown()
: editor.value.getText()

watch(() => props.modelValue, (newVal) => {
if (!editor.value || newVal == null) {
return
}
const newContent = contentType.value === 'json' && typeof newVal === 'object'
? JSON.stringify(newVal)
: String(newVal)

if (currentContent !== newContent) {
// Store current cursor position
const currentSelection = editor.value.state.selection
const currentPos = currentSelection.from

const currentContent = contentType.value === 'html'
? editor.value.getHTML()
: contentType.value === 'json'
? JSON.stringify(editor.value.getJSON())
: contentType.value === 'markdown'
? editor.value.getMarkdown()
: editor.value.getText()

const newContent = contentType.value === 'json' && typeof newVal === 'object'
? JSON.stringify(newVal)
: String(newVal)

if (currentContent !== newContent) {
// Store current cursor position
const currentSelection = editor.value.state.selection
const currentPos = currentSelection.from

// Set the new content
editor.value.commands.setContent(newVal, { contentType: contentType.value })

// Restore cursor position if the position is still valid in the new content
const newDoc = editor.value.state.doc
if (currentPos <= newDoc.content.size) {
editor.value.commands.setTextSelection(currentPos)
// Set the new content
editor.value.commands.setContent(newVal, { contentType: contentType.value })

// Restore cursor position if the position is still valid in the new content
const newDoc = editor.value.state.doc
if (currentPos <= newDoc.content.size) {
editor.value.commands.setTextSelection(currentPos)
}
}
}
})
})
}

// eslint-disable-next-line vue/no-dupe-keys
const handlers = computed(() => ({
Expand All @@ -285,14 +345,17 @@ defineExpose({
<template>
<Primitive :as="props.as" data-slot="root" :class="ui.root({ class: [props.ui?.root, props.class] })">
<template v-if="editor">
<slot :editor="editor" :handlers="handlers" />
<slot :editor="editor" :handlers="handlers" :ui="ui" />

<EditorContent
role="presentation"
:editor="editor"
data-slot="content"
:class="ui.content({ class: props.ui?.content })"
v-bind="isExternalEditor ? $attrs : undefined"

@sandros94 sandros94 Jul 6, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the only thing I'm not fully sure about. I added it because I do tend to use it when in a pinch, but feel free to change @benjamincanac (the v-bind="isExternalEditor ? $attrs : undefined" part)

/>
</template>

<slot v-else name="fallback" />
</Primitive>
</template>
12 changes: 12 additions & 0 deletions src/runtime/composables/useComponentProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ function propIsDefined(vnode: VNode | null | undefined, prop: string): boolean {
|| vnode.props[kebabCase(prop)] !== undefined
}

/**
* Detect, from within a component's `setup`, whether a prop was bound by the parent,
* regardless of its value. Unlike `propIsDefined`, this stays `true` when the bound
* value is `undefined` (e.g. a binding that only resolves after the first render).
* Checks both camelCase and kebab-case names to cover both template conventions.
*/
export function isPropBound(name: string): boolean {
const vnode = getCurrentInstance()?.vnode
if (!vnode || !vnode.props) return false
return camelCase(name) in vnode.props || kebabCase(name) in vnode.props
}

/**
* Resolve a component's props with the priority chain:
* explicit prop > nearest UTheme > withDefaults
Expand Down
Loading
Loading