snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# @tiptap/extension-drag-handle-react
[![Version](https://img.shields.io/npm/v/@tiptap/extension-drag-handle-react.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-drag-handle-react)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-drag-handle-react.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-drag-handle-react.svg)](https://www.npmjs.com/package/@tiptap/extension-drag-handle-react)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@@ -0,0 +1,60 @@
{
"name": "@tiptap/extension-drag-handle-react",
"version": "3.30.3",
"description": "drag handle extension for tiptap with react",
"keywords": [
"tiptap",
"tiptap extension"
],
"homepage": "https://tiptap.dev",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension-drag-handle-react"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"files": [
"src",
"dist"
],
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"devDependencies": {
"@tiptap/extension-drag-handle": "workspace:^",
"@tiptap/pm": "workspace:^",
"@tiptap/react": "workspace:^",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"peerDependencies": {
"@tiptap/extension-drag-handle": "workspace:*",
"@tiptap/pm": "workspace:*",
"@tiptap/react": "workspace:*",
"react": "^16.8 || ^17 || ^18 || ^19",
"react-dom": "^16.8 || ^17 || ^18 || ^19"
}
}

View File

@@ -0,0 +1,96 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { useEditorState } from '@tiptap/react'
import { act, render } from '@testing-library/react'
import React from 'react'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import { DragHandle } from './DragHandle.js'
function createRealEditor() {
const element = document.createElement('div')
document.body.appendChild(element)
return new Editor({
element,
extensions: [Document, Paragraph, Text],
content: '<p>hello world</p>',
})
}
describe('DragHandle plugin registration stability', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
document.body.innerHTML = ''
})
// Re-registering mid-drag tears the handle out of the DOM and breaks dragging.
it('does not re-register the plugin on transactions when onNodeChange is an unstable inline callback', () => {
editor = createRealEditor()
const register = vi.spyOn(editor, 'registerPlugin')
const unregister = vi.spyOn(editor, 'unregisterPlugin')
function Parent({ editor: ed }: { editor: Editor }) {
// Re-render on every transaction, like real apps using useEditor/useEditorState.
useEditorState({ editor: ed, selector: s => s.transactionNumber })
return React.createElement(DragHandle, {
editor: ed,
// A fresh arrow function on every render (the reporter's code).
onNodeChange: () => {},
children: React.createElement('span', null, 'handle'),
} as never)
}
act(() => {
render(React.createElement(Parent, { editor }))
})
const registersAfterMount = register.mock.calls.length
expect(registersAfterMount).toBe(1)
for (let i = 0; i < 5; i += 1) {
act(() => {
editor.view.dispatch(editor.state.tr.insertText('x', 1))
})
}
expect(register.mock.calls.length).toBe(1)
expect(unregister.mock.calls.length).toBe(0)
})
// The ref must forward to the newest callback, not the one from the first render.
it('invokes the most recent onNodeChange after a re-render', () => {
editor = createRealEditor()
const calls: Array<{ tag: string; pos: number }> = []
let bumpTag: (() => void) | undefined
function Parent() {
const [tag, setTag] = React.useState('first')
bumpTag = () => setTag('latest')
return React.createElement(DragHandle, {
editor,
onNodeChange: (data: { pos: number }) => calls.push({ tag, pos: data.pos }),
children: React.createElement('span', null, 'handle'),
} as never)
}
act(() => {
render(React.createElement(Parent))
})
// Re-render with a new callback identity, then fire the hide event.
act(() => {
bumpTag?.()
})
// `hideDragHandle` meta triggers onNodeChange(null, -1) in the plugin.
act(() => {
editor.view.dispatch(editor.state.tr.setMeta('hideDragHandle', true))
})
expect(calls.at(-1)).toEqual({ tag: 'latest', pos: -1 })
})
})

View File

@@ -0,0 +1,105 @@
import { render } from '@testing-library/react'
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { DragHandle } from './DragHandle.js'
const { dragHandlePluginMock, unbindMock } = vi.hoisted(() => ({
dragHandlePluginMock: vi.fn(() => ({
plugin: { spec: { key: 'dragHandle' } },
unbind: unbindMock,
})),
unbindMock: vi.fn(),
}))
vi.mock('@tiptap/extension-drag-handle', () => ({
DragHandlePlugin: dragHandlePluginMock,
dragHandlePluginDefaultKey: 'dragHandle',
defaultComputePositionConfig: {},
normalizeNestedOptions: (value: unknown) => value,
}))
function createEditor() {
return {
isDestroyed: false,
registerPlugin: vi.fn(),
unregisterPlugin: vi.fn(),
}
}
describe('DragHandle', () => {
beforeEach(() => {
dragHandlePluginMock.mockClear()
unbindMock.mockClear()
})
afterEach(() => {
document.body.innerHTML = ''
})
it('survives the React 19 strict-mode double mount with a stable portal element', () => {
const editor = createEditor()
let result: ReturnType<typeof render> | undefined
const renderInStrictMode = () => {
result = render(
React.createElement(
React.StrictMode,
null,
React.createElement(DragHandle, {
editor: editor as never,
children: React.createElement('button', { type: 'button' }, 'Drag'),
} as never),
),
)
}
expect(renderInStrictMode).not.toThrow()
// StrictMode mounts, tears down, then remounts the effect in development.
// Every setup pass must reuse the same element instance created via useRef.
expect(dragHandlePluginMock.mock.calls.length).toBeGreaterThan(1)
const elements = dragHandlePluginMock.mock.calls.map(([options]) => options.element)
const [firstElement] = elements
expect(firstElement).toBeInstanceOf(HTMLDivElement)
expect(elements.every(element => element === firstElement)).toBe(true)
expect(unbindMock).toHaveBeenCalled()
expect(firstElement.textContent).toContain('Drag')
result?.unmount()
expect(editor.unregisterPlugin).toHaveBeenCalledWith('dragHandle')
expect(unbindMock).toHaveBeenCalled()
})
it('updates className without re-registering the plugin', () => {
const editor = createEditor()
const { rerender } = render(
React.createElement(DragHandle, {
editor: editor as never,
className: 'drag-handle',
children: React.createElement('span', null, 'Handle'),
} as never),
)
const registerCount = editor.registerPlugin.mock.calls.length
const unregisterCount = editor.unregisterPlugin.mock.calls.length
const element = dragHandlePluginMock.mock.calls[0][0].element
expect(element.className).toBe('drag-handle')
rerender(
React.createElement(DragHandle, {
editor: editor as never,
className: 'drag-handle-updated',
children: React.createElement('span', null, 'Handle'),
} as never),
)
expect(element.className).toBe('drag-handle-updated')
expect(editor.registerPlugin.mock.calls.length).toBe(registerCount)
expect(editor.unregisterPlugin.mock.calls.length).toBe(unregisterCount)
})
})

View File

@@ -0,0 +1,159 @@
import {
type DragHandlePluginProps,
type NestedOptions,
defaultComputePositionConfig,
DragHandlePlugin,
dragHandlePluginDefaultKey,
normalizeNestedOptions,
} from '@tiptap/extension-drag-handle'
import type { Node as PMNode } from '@tiptap/pm/model'
import type { Editor } from '@tiptap/react'
import { type ReactNode, useEffect, useMemo, useRef } from 'react'
import { createPortal } from 'react-dom'
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>
export type DragHandleProps = Omit<
Optional<DragHandlePluginProps, 'pluginKey'>,
'element' | 'nestedOptions'
> & {
className?: string
onNodeChange?: (data: { node: PMNode | null; editor: Editor; pos: number }) => void
children: ReactNode
/**
* 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.
*
* @default false
*
* @example
* // Simple enable with sensible defaults
* <DragHandle editor={editor} nested>
* <GripIcon />
* </DragHandle>
*
* @example
* // With custom configuration
* <DragHandle
* editor={editor}
* nested={{
* edgeDetection: 'left',
* allowedContainers: ['bulletList', 'orderedList'],
* }}
* >
* <GripIcon />
* </DragHandle>
*
* @example
* // With custom rules
* <DragHandle
* editor={editor}
* nested={{
* rules: [{
* id: 'excludeCodeBlocks',
* evaluate: ({ node }) => node.type.name === 'codeBlock' ? 1000 : 0,
* }],
* }}
* >
* <GripIcon />
* </DragHandle>
*/
nested?: boolean | NestedOptions
}
export const DragHandle = (props: DragHandleProps) => {
const {
className = 'drag-handle',
children,
editor,
pluginKey = dragHandlePluginDefaultKey,
onNodeChange,
onElementDragStart,
onElementDragEnd,
getReferencedVirtualElement,
computePositionConfig = defaultComputePositionConfig,
nested = false,
} = props
const elementRef = useRef<HTMLDivElement | null>(null)
if (elementRef.current === null && typeof document !== 'undefined') {
elementRef.current = document.createElement('div')
}
// Keep callbacks in a ref so an unstable identity can't re-register the plugin.
const callbacks = {
onNodeChange,
onElementDragStart,
onElementDragEnd,
getReferencedVirtualElement,
}
const callbacksRef = useRef(callbacks)
callbacksRef.current = callbacks
// oxlint-disable-next-line react-hooks/exhaustive-deps
const nestedOptions = useMemo(() => normalizeNestedOptions(nested), [JSON.stringify(nested)])
useEffect(() => {
const element = elementRef.current
if (!element) {
return
}
element.className = className
}, [className])
useEffect(() => {
if (typeof document === 'undefined') {
return
}
if (editor.isDestroyed) {
return
}
const element = elementRef.current
if (!element) {
return
}
element.style.visibility = 'hidden'
element.style.position = 'absolute'
element.dataset.dragging = 'false'
const { plugin, unbind } = DragHandlePlugin({
editor,
element,
pluginKey,
computePositionConfig: {
...defaultComputePositionConfig,
...computePositionConfig,
},
onElementDragStart: event => callbacksRef.current.onElementDragStart?.(event),
onElementDragEnd: event => callbacksRef.current.onElementDragEnd?.(event),
onNodeChange: data => callbacksRef.current.onNodeChange?.(data),
getReferencedVirtualElement: () =>
callbacksRef.current.getReferencedVirtualElement?.() ?? null,
nestedOptions,
})
editor.registerPlugin(plugin)
return () => {
if (!editor.isDestroyed) {
editor.unregisterPlugin(pluginKey)
}
unbind()
}
}, [editor, pluginKey, computePositionConfig, nestedOptions])
const element = elementRef.current
if (!element) {
return null
}
return createPortal(children, element)
}

View File

@@ -0,0 +1,5 @@
import { DragHandle } from './DragHandle.js'
export * from './DragHandle.js'
export default DragHandle

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite-plus'
import { basePackConfig } from '../../pack.config.mjs'
export default defineConfig({
pack: {
entry: ['src/index.ts'],
...basePackConfig(),
},
})