EditorDragHandle

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

Usage

The EditorDragHandle component provides drag-and-drop functionality for reordering editor blocks using the @tiptap/extension-drag-handle-vue-3 package.

It must be used inside an Editor component's default slot to have access to the editor instance.

It extends the Button component, so you can pass any property such as color, variant, size, etc.

<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>
  <PEditor v-slot="{ editor }" v-model="value" content-type="markdown" class="min-h-21 w-full">
    <PEditorDragHandle :editor="editor" />
  </PEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'

const value = ref(`# Drag Handle

Hover over the left side of this block to see the drag handle appear and reorder blocks.`)
</script>

<template>
  <PEditor v-slot="{ editor }" v-model="value" content-type="markdown" class="min-h-21 w-full">
    <PEditorDragHandle :editor="editor" />
  </PEditor>
</template>
Learn more about the Drag Handle extension in the TipTap documentation.

Icon

Use the icon prop to customize the drag handle icon.

<template>
  <PEditor v-slot="{ editor }">
    <PEditorDragHandle
      :editor="editor"
      icon="i-lucide-move"
    />
  </PEditor>
</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>
  <PEditor v-slot="{ editor }">
    <PEditorDragHandle
      :editor="editor"
      :options="{
        placement: 'left',
      }"
    />
  </PEditor>
</template>

Examples

With dropdown menu

Use the default slot to add a DropdownMenu with block-level actions like duplicate, delete, move up/down, or transform blocks into different types.

Listen to the @node-change event to track the currently hovered node and its position, then use editor.chain().setMeta('lockDragHandle', open).run() to lock the handle position while the menu is open.

<script setup lang="ts">
import type { Editor, JSONContent } from '@tiptap/vue-3';
import type { DropdownMenuItem } from 'pohon-ui';
import { capitalize } from '@vinicunca/perkakas';
import { mapEditorItems } from 'pohon-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: JSONContent; pos: number }>();

function items(editor: Editor): Array<Array<DropdownMenuItem>> {
  if (!selectedNode.value?.node?.type) {
    return [];
  }

  return mapEditorItems(editor, [[
    {
      type: 'label',
      label: capitalize(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 Array<Array<DropdownMenuItem>>;
}
</script>

<template>
  <PEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    class="min-h-19 w-full"
  >
    <PEditorDragHandle
      v-slot="{ ui }"
      :editor="editor"
      @node-change="selectedNode = $event"
    >
      <PDropdownMenu
        v-slot="{ open }"
        :modal="false"
        :items="items(editor)"
        :content="{ side: 'left' }"
        :ui="{ content: 'w-48', label: 'text-xs' }"
        @update:open="editor.chain().setMeta('lockDragHandle', $event).run()"
      >
        <PButton
          icon="i-lucide-grip-vertical"
          color="neutral"
          variant="ghost"
          active-variant="soft"
          size="sm"
          :active="open"
          :class="ui.handle()"
        />
      </PDropdownMenu>
    </PEditorDragHandle>
  </PEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { Editor, JSONContent } from '@tiptap/vue-3';
import type { DropdownMenuItem } from 'pohon-ui';
import { capitalize } from '@vinicunca/perkakas';
import { mapEditorItems } from 'pohon-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: JSONContent; pos: number }>();

function items(editor: Editor): Array<Array<DropdownMenuItem>> {
  if (!selectedNode.value?.node?.type) {
    return [];
  }

  return mapEditorItems(editor, [[
    {
      type: 'label',
      label: capitalize(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 Array<Array<DropdownMenuItem>>;
}
</script>

<template>
  <PEditor
    v-slot="{ editor }"
    v-model="value"
    content-type="markdown"
    class="min-h-19 w-full"
  >
    <PEditorDragHandle
      v-slot="{ ui }"
      :editor="editor"
      @node-change="selectedNode = $event"
    >
      <PDropdownMenu
        v-slot="{ open }"
        :modal="false"
        :items="items(editor)"
        :content="{ side: 'left' }"
        :ui="{ content: 'w-48', label: 'text-xs' }"
        @update:open="editor.chain().setMeta('lockDragHandle', $event).run()"
      >
        <PButton
          icon="i-lucide-grip-vertical"
          color="neutral"
          variant="ghost"
          active-variant="soft"
          size="sm"
          :active="open"
          :class="ui.handle()"
        />
      </PDropdownMenu>
    </PEditorDragHandle>
  </PEditor>
</template>
This example uses the mapEditorItems utility from pohon-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 next to the drag handle to open the EditorSuggestionMenu.

Call the onClick slot function to get the current node position, then use handlers.suggestion?.execute(editor, { pos: node?.pos }).run() to insert new blocks at that position.

<script setup lang="ts">
import type { EditorSuggestionMenuItem } from 'pohon-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: Array<Array<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>
  <PEditor
    v-slot="{ editor, handlers }"
    v-model="value"
    content-type="markdown"
    class="min-h-35 w-full"
    :ui="{ base: 'p-8 sm:px-16' }"
  >
    <PEditorSuggestionMenu
      :editor="editor"
      :items="suggestionItems"
    />

    <PEditorDragHandle
      v-slot="{ ui, onClick }"
      :editor="editor"
    >
      <PButton
        icon="i-lucide-plus"
        color="neutral"
        variant="ghost"
        size="sm"
        :class="ui.handle()"
        @click="(e) => {
          e.stopPropagation()

          const selected = onClick()
          handlers.suggestion?.execute(editor, { pos: selected?.pos }).run()
        }"
      />

      <PButton
        icon="i-lucide-grip-vertical"
        color="neutral"
        variant="ghost"
        size="sm"
        :class="ui.handle()"
      />
    </PEditorDragHandle>
  </PEditor>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorSuggestionMenuItem } from 'pohon-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: Array<Array<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>
  <PEditor
    v-slot="{ editor, handlers }"
    v-model="value"
    content-type="markdown"
    class="min-h-35 w-full"
    :ui="{ base: 'p-8 sm:px-16' }"
  >
    <PEditorSuggestionMenu
      :editor="editor"
      :items="suggestionItems"
    />

    <PEditorDragHandle
      v-slot="{ ui, onClick }"
      :editor="editor"
    >
      <PButton
        icon="i-lucide-plus"
        color="neutral"
        variant="ghost"
        size="sm"
        :class="ui.handle()"
        @click="(e) => {
          e.stopPropagation()

          const selected = onClick()
          handlers.suggestion?.execute(editor, { pos: selected?.pos }).run()
        }"
      />

      <PButton
        icon="i-lucide-grip-vertical"
        color="neutral"
        variant="ghost"
        size="sm"
        :class="ui.handle()"
      />
    </PEditorDragHandle>
  </PEditor>
</template>

API

Props

Prop Default Type
as
'button'
any

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

editor
Editor
icon
appConfig.ui.icons.drag
any
color
'neutral'
"primary" | "secondary" | "success" | "info" | "warning" | "error" | "neutral"
variant
'ghost'
"solid" | "outline" | "soft" | "subtle" | "ghost" | "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>
nestedOptions
NormalizedNestedOptions
onElementDragStart
(e: DragEvent): void
onElementDragEnd
(e: DragEvent): void
getReferencedVirtualElement
(): VirtualElement | null
dragImageProperties
string[]
nested
boolean | NestedOptions

Enable drag handles for nested content (list items, blockquotes, etc.).

When enabled, the drag handle will appear for nested blocks, not just top-level blocks. A rule-based scoring system determines which node to target based on cursor position and configured rules.

autofocus
false | true | "true" | "false"
disabled
boolean
name
string
type
'button'
"button" | "submit" | "reset"

The type of the button when not a link.

label
string
activeColor
"primary" | "secondary" | "success" | "info" | "warning" | "error" | "neutral"
activeVariant
"solid" | "outline" | "soft" | "subtle" | "ghost" | "link"
size
'md'
"xs" | "sm" | "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 @click promise state

avatar
AvatarProps

Display an avatar on the left side.

leading
boolean

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

leadingIcon
any

Display an icon on the left side.

trailing
boolean

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

trailingIcon
any

Display an icon on the right side.

loading
boolean

When true, the loading icon will be displayed.

loadingIcon
appConfig.ui.icons.loading
any

The icon when the loading prop is true.

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

Slots

Slot Type
default
{ ui: object; }

Emits

Event Type
nodeChange
[args_0: { node: JSONContent; pos: number; }]
hover
[args_0: { node: JSONContent; pos: number; }]

Theme

app.config.ts
export default defineAppConfig({
  ui: {
    editorDragHandle: {
      slots: {
        root: 'hidden sm:flex items-center justify-center transition-[top,left]-200 ease-out motion-reduce:transition-none',
        handle: 'cursor-grab px-1'
      }
    }
  }
})
vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from 'pohon-ui/vite'

export default defineConfig({
  plugins: [
    vue(),
    ui({
      ui: {
        editorDragHandle: {
          slots: {
            root: 'hidden sm:flex items-center justify-center transition-[top,left]-200 ease-out motion-reduce:transition-none',
            handle: 'cursor-grab px-1'
          }
        }
      }
    })
  ]
})

Changelog

No recent changes