EditorDragHandle

GitHub
A draggable handle for reordering and selecting blocks in the editor.

Usage

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>
The EditorDragHandle component extends the Button component, so you can pass any property such as color, variant, size, etc.

Icon

Use the icon prop to customize the drag handle icon.

<template>
  <UEditorDragHandle :editor="editor" icon="i-lucide-move" />
</template>
You can customize this icon globally in your app.config.ts under ui.icons.drag key.
You can customize this icon globally in your vite.config.ts under ui.icons.drag key.

Options

Use the options prop to customize the positioning behavior using Floating UI options.

The offset is automatically calculated to center the handle for small blocks and align it to the top for taller blocks.
<template>
  <UEditorDragHandle
    :editor="editor"
    :options="{
      placement: 'left'
    }"
  />
</template>

Examples

With dropdown menu

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>
This example uses the 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.

With suggestion menu

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>

API

Props

Prop Default Type
as'button'any

The element or component this component should render as when not a link.

editorEditor
iconappConfig.ui.icons.dragany
color'neutral' "error" | "neutral" | "primary" | "secondary" | "success" | "info" | "warning"
variant'ghost' "ghost" | "solid" | "outline" | "soft" | "subtle" | "link"
options{ strategy: 'absolute', placement: 'left-start' } FloatingUIOptions

The 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"
disabledboolean
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"
squareboolean

Render the button with equal padding on all sides.

blockboolean

Render the button full width.

loadingAutoboolean

Set loading state automatically based on the @click promise state

avatar AvatarProps

Display an avatar on the left side.

leadingboolean

When true, the icon will be displayed on the left side.

leadingIconany

Display an icon on the left side.

trailingboolean

When true, the icon will be displayed on the right side.

trailingIconany

Display an icon on the right side.

loadingboolean

When true, the loading icon will be displayed.

loadingIconappConfig.ui.icons.loadingany

The icon when the loading prop is true.

ui { root?: ClassNameValue; handle?: ClassNameValue; } & { base?: ClassNameValue; label?: ClassNameValue; leadingIcon?: ClassNameValue; leadingAvatar?: ClassNameValue; leadingAvatarSize?: ClassNameValue; trailingIcon?: ClassNameValue; }

Slots

Slot Type
default{ ui: object; }

Emits

Event Type
nodeChange[{ node: Node<any, any> | null; pos: number; }]

Theme

app.config.ts
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'
      }
    }
  }
})
vite.config.ts
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'
          }
        }
      }
    })
  ]
})

Changelog

No recent changes