{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "block-draggable",
  "title": "Block Draggable",
  "description": "A block wrapper with a drag handle for moving editor blocks.",
  "dependencies": [
    "@platejs/dnd",
    "@platejs/selection"
  ],
  "registryDependencies": [
    "tooltip",
    "https://platejs.org/r/use-mounted.json"
  ],
  "files": [
    {
      "path": "src/registry/ui/block-draggable.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\n\nimport { DndPlugin, useDraggable, useDropLine } from '@platejs/dnd';\nimport { expandListItemsWithChildren } from '@platejs/list';\nimport { BlockSelectionPlugin } from '@platejs/selection/react';\nimport { GripVertical } from 'lucide-react';\nimport { type TElement, getPluginByType, isType, KEYS } from 'platejs';\nimport {\n  type PlateEditor,\n  type PlateElementProps,\n  type RenderNodeWrapper,\n  MemoizedChildren,\n  useEditorRef,\n  useElement,\n  usePluginOption,\n} from 'platejs/react';\nimport { useSelected } from 'platejs/react';\n\nimport { Button } from '@/components/ui/button';\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from '@/components/ui/tooltip';\nimport { cn } from '@/lib/utils';\n\nconst UNDRAGGABLE_KEYS = [KEYS.column, KEYS.tr, KEYS.td];\n\nexport const BlockDraggable: RenderNodeWrapper = (props) => {\n  const { editor, element, path } = props;\n\n  const enabled = React.useMemo(() => {\n    if (editor.dom.readOnly) return false;\n\n    if (path.length === 1 && !isType(editor, element, UNDRAGGABLE_KEYS)) {\n      return true;\n    }\n    if (path.length === 3 && !isType(editor, element, UNDRAGGABLE_KEYS)) {\n      const block = editor.api.some({\n        at: path,\n        match: {\n          type: editor.getType(KEYS.column),\n        },\n      });\n\n      if (block) {\n        return true;\n      }\n    }\n    if (path.length === 4 && !isType(editor, element, UNDRAGGABLE_KEYS)) {\n      const block = editor.api.some({\n        at: path,\n        match: {\n          type: editor.getType(KEYS.table),\n        },\n      });\n\n      if (block) {\n        return true;\n      }\n    }\n\n    return false;\n  }, [editor, element, path]);\n\n  if (!enabled) return;\n\n  return (props) => <Draggable {...props} />;\n};\n\nfunction Draggable(props: PlateElementProps) {\n  const { children, editor, element, path } = props;\n  const blockSelectionApi = editor.getApi(BlockSelectionPlugin).blockSelection;\n\n  const { isAboutToDrag, isDragging, nodeRef, previewRef, handleRef } =\n    useDraggable({\n      element,\n      onDropHandler: (_, { dragItem }) => {\n        const id = (dragItem as { id: string[] | string }).id;\n\n        if (blockSelectionApi) {\n          blockSelectionApi.add(id);\n        }\n        resetPreview();\n      },\n    });\n\n  const isInColumn = path.length === 3;\n  const isInTable = path.length === 4;\n\n  const [previewTop, setPreviewTop] = React.useState(0);\n\n  const resetPreview = () => {\n    if (previewRef.current) {\n      previewRef.current.replaceChildren();\n      previewRef.current?.classList.add('hidden');\n    }\n  };\n\n  // clear up virtual multiple preview when drag end\n  React.useEffect(() => {\n    if (!isDragging) {\n      resetPreview();\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isDragging]);\n\n  React.useEffect(() => {\n    if (isAboutToDrag) {\n      previewRef.current?.classList.remove('opacity-0');\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [isAboutToDrag]);\n\n  const [dragButtonTop, setDragButtonTop] = React.useState(0);\n\n  return (\n    <div\n      className={cn(\n        'relative',\n        isDragging && 'opacity-50',\n        getPluginByType(editor, element.type)?.node.isContainer\n          ? 'group/container'\n          : 'group'\n      )}\n      onMouseEnter={() => {\n        if (isDragging) return;\n        setDragButtonTop(calcDragButtonTop(editor, element));\n      }}\n    >\n      {!isInTable && (\n        <Gutter>\n          <div\n            className={cn(\n              'slate-blockToolbarWrapper',\n              'flex h-[1.5em]',\n              isInColumn && 'h-4'\n            )}\n          >\n            <div\n              className={cn(\n                'slate-blockToolbar relative w-4.5',\n                'pointer-events-auto mr-1 flex items-center',\n                isInColumn && 'mr-1.5'\n              )}\n            >\n              <Button\n                ref={handleRef}\n                variant=\"ghost\"\n                className=\"-left-0 absolute h-6 w-full p-0\"\n                style={{ top: `${dragButtonTop + 3}px` }}\n                data-plate-prevent-deselect\n              >\n                <DragHandle\n                  isDragging={isDragging}\n                  previewRef={previewRef}\n                  resetPreview={resetPreview}\n                  setPreviewTop={setPreviewTop}\n                />\n              </Button>\n            </div>\n          </div>\n        </Gutter>\n      )}\n\n      <div\n        ref={previewRef}\n        className={cn('-left-0 absolute hidden w-full')}\n        style={{ top: `${-previewTop}px` }}\n        contentEditable={false}\n      />\n\n      <div\n        ref={nodeRef}\n        className=\"slate-blockWrapper flow-root\"\n        onContextMenu={(event) =>\n          editor\n            .getApi(BlockSelectionPlugin)\n            .blockSelection.addOnContextMenu({ element, event })\n        }\n      >\n        <MemoizedChildren>{children}</MemoizedChildren>\n        <DropLine />\n      </div>\n    </div>\n  );\n}\n\nfunction Gutter({\n  children,\n  className,\n  ...props\n}: React.ComponentProps<'div'>) {\n  const editor = useEditorRef();\n  const element = useElement();\n  const isSelectionAreaVisible = usePluginOption(\n    BlockSelectionPlugin,\n    'isSelectionAreaVisible'\n  );\n  const selected = useSelected();\n\n  return (\n    <div\n      {...props}\n      className={cn(\n        'slate-gutterLeft',\n        '-translate-x-full absolute top-0 z-50 flex h-full cursor-text hover:opacity-100 sm:opacity-0',\n        getPluginByType(editor, element.type)?.node.isContainer\n          ? 'group-hover/container:opacity-100'\n          : 'group-hover:opacity-100',\n        isSelectionAreaVisible && 'hidden',\n        !selected && 'opacity-0',\n        className\n      )}\n      contentEditable={false}\n    >\n      {children}\n    </div>\n  );\n}\n\nconst DragHandle = React.memo(function DragHandle({\n  isDragging,\n  previewRef,\n  resetPreview,\n  setPreviewTop,\n}: {\n  isDragging: boolean;\n  previewRef: React.RefObject<HTMLDivElement | null>;\n  resetPreview: () => void;\n  setPreviewTop: (top: number) => void;\n}) {\n  const editor = useEditorRef();\n  const element = useElement();\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <div\n          className=\"flex size-full items-center justify-center\"\n          onClick={(e) => {\n            e.preventDefault();\n            editor.getApi(BlockSelectionPlugin).blockSelection.focus();\n          }}\n          onMouseDown={(e) => {\n            resetPreview();\n\n            if ((e.button !== 0 && e.button !== 2) || e.shiftKey) return;\n\n            const blockSelection = editor\n              .getApi(BlockSelectionPlugin)\n              .blockSelection.getNodes({ sort: true });\n\n            let selectionNodes =\n              blockSelection.length > 0\n                ? blockSelection\n                : editor.api.blocks({ mode: 'highest' });\n\n            // If current block is not in selection, use it as the starting point\n            if (!selectionNodes.some(([node]) => node.id === element.id)) {\n              selectionNodes = [[element, editor.api.findPath(element)!]];\n            }\n\n            // Process selection nodes to include list children\n            const blocks = expandListItemsWithChildren(\n              editor,\n              selectionNodes\n            ).map(([node]) => node);\n\n            if (blockSelection.length === 0) {\n              editor.tf.blur();\n              editor.tf.collapse();\n            }\n\n            const elements = createDragPreviewElements(editor, blocks);\n            previewRef.current?.append(...elements);\n            previewRef.current?.classList.remove('hidden');\n            previewRef.current?.classList.add('opacity-0');\n            editor.setOption(DndPlugin, 'multiplePreviewRef', previewRef);\n\n            editor\n              .getApi(BlockSelectionPlugin)\n              .blockSelection.set(blocks.map((block) => block.id as string));\n          }}\n          onMouseEnter={() => {\n            if (isDragging) return;\n\n            const blockSelection = editor\n              .getApi(BlockSelectionPlugin)\n              .blockSelection.getNodes({ sort: true });\n\n            let selectedBlocks =\n              blockSelection.length > 0\n                ? blockSelection\n                : editor.api.blocks({ mode: 'highest' });\n\n            // If current block is not in selection, use it as the starting point\n            if (!selectedBlocks.some(([node]) => node.id === element.id)) {\n              selectedBlocks = [[element, editor.api.findPath(element)!]];\n            }\n\n            // Process selection to include list children\n            const processedBlocks = expandListItemsWithChildren(\n              editor,\n              selectedBlocks\n            );\n\n            const ids = processedBlocks.map((block) => block[0].id as string);\n\n            if (ids.length > 1 && ids.includes(element.id as string)) {\n              const previewTop = calculatePreviewTop(editor, {\n                blocks: processedBlocks.map((block) => block[0]),\n                element,\n              });\n              setPreviewTop(previewTop);\n            } else {\n              setPreviewTop(0);\n            }\n          }}\n          onMouseUp={() => {\n            resetPreview();\n          }}\n          data-plate-prevent-deselect\n          role=\"button\"\n        >\n          <GripVertical className=\"text-muted-foreground\" />\n        </div>\n      </TooltipTrigger>\n      <TooltipContent>Drag to move</TooltipContent>\n    </Tooltip>\n  );\n});\n\nconst DropLine = React.memo(function DropLine({\n  className,\n  ...props\n}: React.ComponentProps<'div'>) {\n  const { dropLine } = useDropLine();\n\n  if (!dropLine) return null;\n\n  return (\n    <div\n      {...props}\n      className={cn(\n        'slate-dropLine',\n        'absolute inset-x-0 h-0.5 opacity-100 transition-opacity',\n        'bg-brand/50',\n        dropLine === 'top' && '-top-px',\n        dropLine === 'bottom' && '-bottom-px',\n        className\n      )}\n    />\n  );\n});\n\nconst createDragPreviewElements = (\n  editor: PlateEditor,\n  blocks: TElement[]\n): HTMLElement[] => {\n  const elements: HTMLElement[] = [];\n  const ids: string[] = [];\n\n  /**\n   * Remove data attributes from the element to avoid recognized as slate\n   * elements incorrectly.\n   */\n  const removeDataAttributes = (element: HTMLElement) => {\n    Array.from(element.attributes).forEach((attr) => {\n      if (\n        attr.name.startsWith('data-slate') ||\n        attr.name.startsWith('data-block-id')\n      ) {\n        element.removeAttribute(attr.name);\n      }\n    });\n\n    Array.from(element.children).forEach((child) => {\n      removeDataAttributes(child as HTMLElement);\n    });\n  };\n\n  const resolveElement = (node: TElement, index: number) => {\n    const domNode = editor.api.toDOMNode(node)!;\n    const newDomNode = domNode.cloneNode(true) as HTMLElement;\n\n    // Apply visual compensation for horizontal scroll\n    const applyScrollCompensation = (\n      original: Element,\n      cloned: HTMLElement\n    ) => {\n      const scrollLeft = original.scrollLeft;\n\n      if (scrollLeft > 0) {\n        // Create a wrapper to handle the scroll offset\n        const scrollWrapper = document.createElement('div');\n        scrollWrapper.style.overflow = 'hidden';\n        scrollWrapper.style.width = `${original.clientWidth}px`;\n\n        // Create inner container with the full content\n        const innerContainer = document.createElement('div');\n        innerContainer.style.transform = `translateX(-${scrollLeft}px)`;\n        innerContainer.style.width = `${original.scrollWidth}px`;\n\n        // Move all children to the inner container\n        while (cloned.firstChild) {\n          innerContainer.append(cloned.firstChild);\n        }\n\n        // Apply the original element's styles to maintain appearance\n        const originalStyles = window.getComputedStyle(original);\n        cloned.style.padding = '0';\n        innerContainer.style.padding = originalStyles.padding;\n\n        scrollWrapper.append(innerContainer);\n        cloned.append(scrollWrapper);\n      }\n    };\n\n    applyScrollCompensation(domNode, newDomNode);\n\n    ids.push(node.id as string);\n    const wrapper = document.createElement('div');\n    wrapper.append(newDomNode);\n    wrapper.style.display = 'flow-root';\n\n    const lastDomNode = blocks[index - 1];\n\n    if (lastDomNode) {\n      const lastDomNodeRect = editor.api\n        .toDOMNode(lastDomNode)!\n        .parentElement!.getBoundingClientRect();\n\n      const domNodeRect = domNode.parentElement!.getBoundingClientRect();\n\n      const distance = domNodeRect.top - lastDomNodeRect.bottom;\n\n      // Check if the two elements are adjacent (touching each other)\n      if (distance > 15) {\n        wrapper.style.marginTop = `${distance}px`;\n      }\n    }\n\n    removeDataAttributes(newDomNode);\n    elements.push(wrapper);\n  };\n\n  blocks.forEach((node, index) => {\n    resolveElement(node, index);\n  });\n\n  editor.setOption(DndPlugin, 'draggingId', ids);\n\n  return elements;\n};\n\nconst calculatePreviewTop = (\n  editor: PlateEditor,\n  {\n    blocks,\n    element,\n  }: {\n    blocks: TElement[];\n    element: TElement;\n  }\n): number => {\n  const child = editor.api.toDOMNode(element)!;\n  const editable = editor.api.toDOMNode(editor)!;\n  const firstSelectedChild = blocks[0];\n\n  const firstDomNode = editor.api.toDOMNode(firstSelectedChild)!;\n  // Get editor's top padding\n  const editorPaddingTop = Number(\n    window.getComputedStyle(editable).paddingTop.replace('px', '')\n  );\n\n  // Calculate distance from first selected node to editor top\n  const firstNodeToEditorDistance =\n    firstDomNode.getBoundingClientRect().top -\n    editable.getBoundingClientRect().top -\n    editorPaddingTop;\n\n  // Get margin top of first selected node\n  const firstMarginTopString = window.getComputedStyle(firstDomNode).marginTop;\n  const marginTop = Number(firstMarginTopString.replace('px', ''));\n\n  // Calculate distance from current node to editor top\n  const currentToEditorDistance =\n    child.getBoundingClientRect().top -\n    editable.getBoundingClientRect().top -\n    editorPaddingTop;\n\n  const currentMarginTopString = window.getComputedStyle(child).marginTop;\n  const currentMarginTop = Number(currentMarginTopString.replace('px', ''));\n\n  const previewElementsTopDistance =\n    currentToEditorDistance -\n    firstNodeToEditorDistance +\n    marginTop -\n    currentMarginTop;\n\n  return previewElementsTopDistance;\n};\n\nconst calcDragButtonTop = (editor: PlateEditor, element: TElement): number => {\n  const child = editor.api.toDOMNode(element)!;\n\n  const currentMarginTopString = window.getComputedStyle(child).marginTop;\n  const currentMarginTop = Number(currentMarginTopString.replace('px', ''));\n\n  return currentMarginTop;\n};\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "docs": [
      {
        "route": "/docs/dnd",
        "title": "Drag & Drop"
      },
      {
        "route": "https://pro.platejs.org/docs/components/block-draggable"
      }
    ],
    "examples": [
      "dnd-demo",
      "dnd-pro"
    ],
    "usage": [
      "DndPlugin.configure({\n  render: {\n    aboveNodes: BlockDraggable,\n  },\n})"
    ]
  },
  "type": "registry:ui"
}