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"
placeholder="Start typing..."
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 dropdown menu with block-level actions and listen to the @node-change event to track the currently hovered node.
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui'
import type { Editor, Node } from '@tiptap/vue-3'
import { mapEditorItems } from '@nuxt/ui/utils/editor'
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) return []
return mapEditorItems(editor, [[{
kind: 'duplicate',
pos: selectedNode.value.pos,
label: 'Duplicate',
icon: 'i-lucide-copy'
}], [{
kind: 'delete',
pos: selectedNode.value.pos,
label: 'Delete',
icon: 'i-lucide-trash'
}]]) as DropdownMenuItem[][]
}
</script>
<template>
<UEditor v-slot="{ editor }" v-model="value" content-type="markdown" placeholder="Start typing...">
<UEditorDragHandle
v-slot="{ ui }"
:editor="editor"
@node-change="selectedNode = $event"
>
<UButton
icon="i-lucide-grip-vertical"
color="neutral"
variant="ghost"
size="sm"
:class="ui.handle()"
/>
<UDropdownMenu
:items="getMenuItems(editor)"
:content="{ side: 'right' }"
:ui="{ content: 'w-48' }"
>
<UButton
icon="i-lucide-more-vertical"
color="neutral"
variant="ghost"
size="sm"
: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.Add a button to insert new content at the current block position.
<script setup lang="ts">
const value = ref(`Click the plus button to add a new paragraph below the current block.
The button appears when hovering over blocks.`)
</script>
<template>
<UEditor v-slot="{ editor }" v-model="value" content-type="markdown" placeholder="Start typing...">
<UEditorDragHandle
v-slot="{ ui, onClick }"
:editor="editor"
>
<UButton
icon="i-lucide-plus"
color="primary"
variant="ghost"
size="sm"
:class="ui.handle()"
@click="(e) => {
const node = onClick(e)
if (node) {
editor.chain().focus().insertContentAt(node.pos + 1, { type: 'paragraph' }).run()
}
}"
/>
<UButton
icon="i-lucide-grip-vertical"
color="neutral"
variant="ghost"
size="sm"
:class="ui.handle()"
/>
</UEditorDragHandle>
</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'
}
}
}
})
]
})