The EditorDragHandle component wraps TipTap's Drag Handle extension to provide drag-and-drop functionality for editor blocks. It must be used inside an Editor component's default slot to have access to the editor instance.
<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>
color, variant, size, etc.Use the icon prop to customize the drag handle icon.
<template>
<UEditorDragHandle :editor="editor" icon="i-lucide-move" />
</template>
Use the options prop to customize the positioning behavior using Floating UI options.
<template>
<UEditorDragHandle
:editor="editor"
:options="{
placement: 'left'
}"
/>
</template>
Use the default slot to add a DropdownMenu with block-level actions and listen to the @node-change event to track the currently hovered node.
<script setup lang="ts">
import { upperFirst } from 'scule'
import type { DropdownMenuItem } from '@nuxt/ui'
import { mapEditorItems } from '@nuxt/ui/utils/editor'
import type { Editor, Node } from '@tiptap/vue-3'
const value = ref(`Hover over the left side to see both drag handle and menu button.
Click the menu to see block actions. Try duplicating or deleting a block.`)
const selectedNode = ref<{ node: Node | null, pos: number }>()
const getMenuItems = (editor: Editor): DropdownMenuItem[][] => {
if (!selectedNode.value?.node) {
return []
}
return mapEditorItems(editor, [[
{
type: 'label',
label: upperFirst(selectedNode.value.node.type)
},
{
label: 'Turn into',
icon: 'i-lucide-repeat-2',
children: [
{ 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' },
{ kind: 'heading', level: 4, label: 'Heading 4', icon: 'i-lucide-heading-4' },
{ kind: 'bulletList', label: 'Bullet List', icon: 'i-lucide-list' },
{ kind: 'orderedList', label: 'Ordered List', icon: 'i-lucide-list-ordered' },
{ kind: 'blockquote', label: 'Blockquote', icon: 'i-lucide-text-quote' },
{ kind: 'codeBlock', label: 'Code Block', icon: 'i-lucide-square-code' }
]
},
{
kind: 'clearFormatting',
pos: selectedNode.value?.pos,
label: 'Reset formatting',
icon: 'i-lucide-rotate-ccw'
}
], [
{
kind: 'duplicate',
pos: selectedNode.value?.pos,
label: 'Duplicate',
icon: 'i-lucide-copy'
},
{
label: 'Copy to clipboard',
icon: 'i-lucide-clipboard',
onSelect: async () => {
if (!selectedNode.value) return
const pos = selectedNode.value.pos
const node = editor.state.doc.nodeAt(pos)
if (node) {
await navigator.clipboard.writeText(node.textContent)
}
}
}
], [
{
kind: 'moveUp',
pos: selectedNode.value?.pos,
label: 'Move up',
icon: 'i-lucide-arrow-up'
},
{
kind: 'moveDown',
pos: selectedNode.value?.pos,
label: 'Move down',
icon: 'i-lucide-arrow-down'
}
], [
{
kind: 'delete',
pos: selectedNode.value?.pos,
label: 'Delete',
icon: 'i-lucide-trash'
}
]]) as DropdownMenuItem[][]
}
</script>
<template>
<UEditor v-slot="{ editor }" v-model="value" class="w-full min-h-21">
<UEditorDragHandle v-slot="{ ui }" :editor="editor" @node-change="selectedNode = $event">
<UDropdownMenu
v-slot="{ open }"
:modal="false"
:items="getMenuItems(editor)"
:content="{ side: 'left' }"
:ui="{ content: 'w-48', label: 'text-xs' }"
@update:open="editor.chain().setMeta('lockDragHandle', $event).run()"
>
<UButton
icon="i-lucide-more-vertical"
color="neutral"
variant="ghost"
active-variant="soft"
size="sm"
:active="open"
:class="ui.handle()"
/>
</UDropdownMenu>
</UEditorDragHandle>
</UEditor>
</template>
mapEditorItems utility from @nuxt/ui/utils/editor to automatically map handler kinds (like duplicate, delete, moveUp, etc.) to their corresponding editor commands with proper state management.Use the default slot to add a button that triggers the suggestion handler and open the EditorSuggestionMenu component.
<script setup lang="ts">
import type { EditorSuggestionMenuItem } from '@nuxt/ui'
const value = ref(`Click the plus button to open the suggestion menu and add new blocks.
The button appears when hovering over blocks.`)
const suggestionItems: EditorSuggestionMenuItem[][] = [[{
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: 'bulletList',
label: 'Bullet List',
icon: 'i-lucide-list'
}, {
kind: 'blockquote',
label: 'Blockquote',
icon: 'i-lucide-text-quote'
}]]
</script>
<template>
<UEditor v-slot="{ editor, handlers }" v-model="value" class="w-full min-h-21">
<UEditorDragHandle v-slot="{ ui, onClick }" :editor="editor">
<UButton
icon="i-lucide-plus"
color="neutral"
variant="ghost"
size="sm"
:class="ui.handle()"
@click="(e) => {
e.stopPropagation()
const node = onClick(e)
handlers.suggestion?.execute(editor, { pos: node?.pos }).run()
}"
/>
<UButton
icon="i-lucide-grip-vertical"
color="neutral"
variant="ghost"
size="sm"
:class="ui.handle()"
/>
</UEditorDragHandle>
<UEditorSuggestionMenu :editor="editor" :items="suggestionItems" />
</UEditor>
</template>
| Prop | Default | Type |
|---|---|---|
as | 'button' | anyThe element or component this component should render as when not a link. |
editor | Editor | |
icon | appConfig.ui.icons.drag | any |
color | 'neutral' | "error" | "neutral" | "primary" | "secondary" | "success" | "info" | "warning" |
variant | 'ghost' | "ghost" | "solid" | "outline" | "soft" | "subtle" | "link" |
options | { strategy: 'absolute', placement: 'left-start' } | FloatingUIOptionsThe options for positioning the drag handle. Those are passed to Floating UI and include options for the placement, offset, flip, shift, size, autoPlacement, hide, and inline middleware.
|
pluginKey | string | PluginKey<any> | |
onElementDragStart | (e: DragEvent): void | |
onElementDragEnd | (e: DragEvent): void | |
getReferencedVirtualElement | (): VirtualElement | null | |
autofocus | false | true | "true" | "false" | |
disabled | boolean | |
name | string | |
type | 'button' | "reset" | "submit" | "button"The type of the button when not a link. |
label | string | |
activeColor | "error" | "neutral" | "primary" | "secondary" | "success" | "info" | "warning" | |
activeVariant | "ghost" | "solid" | "outline" | "soft" | "subtle" | "link" | |
size | 'sm' | "sm" | "xs" | "md" | "lg" | "xl" |
square | boolean Render the button with equal padding on all sides. | |
block | boolean Render the button full width. | |
loadingAuto | boolean Set loading state automatically based on the | |
avatar | AvatarPropsDisplay an avatar on the left side.
| |
leading | boolean When | |
leadingIcon | anyDisplay an icon on the left side. | |
trailing | boolean When | |
trailingIcon | anyDisplay an icon on the right side. | |
loading | boolean When | |
loadingIcon | appConfig.ui.icons.loading | anyThe icon when the |
ui | { root?: ClassNameValue; handle?: ClassNameValue; } & { base?: ClassNameValue; label?: ClassNameValue; leadingIcon?: ClassNameValue; leadingAvatar?: ClassNameValue; leadingAvatarSize?: ClassNameValue; trailingIcon?: ClassNameValue; }
|
| Slot | Type |
|---|---|
default | { ui: object; } |
| Event | Type |
|---|---|
nodeChange | [{ node: Node<any, any> | null; pos: number; }] |
export default defineAppConfig({
ui: {
editorDragHandle: {
slots: {
root: 'hidden sm:flex items-center justify-center transition-all duration-200 ease-out',
handle: 'cursor-grab px-1'
}
}
}
})
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from '@nuxt/ui/vite'
export default defineConfig({
plugins: [
vue(),
ui({
ui: {
editorDragHandle: {
slots: {
root: 'hidden sm:flex items-center justify-center transition-all duration-200 ease-out',
handle: 'cursor-grab px-1'
}
}
}
})
]
})